From bd08c1341289c5d30facad6bcfed4b02cd33262d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 16:02:36 +0200 Subject: [PATCH 1/6] fix(cli): preserve forked session variants --- .changeset/fork-session-variant.md | 5 +++ packages/opencode/src/session/prompt.ts | 6 +++- packages/opencode/src/session/session.ts | 1 + packages/opencode/test/session/prompt.test.ts | 33 +++++++++++++++++++ .../opencode/test/session/session.test.ts | 27 +++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .changeset/fork-session-variant.md diff --git a/.changeset/fork-session-variant.md b/.changeset/fork-session-variant.md new file mode 100644 index 00000000000..95dffad5818 --- /dev/null +++ b/.changeset/fork-session-variant.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve the selected model reasoning variant when forking a session. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 56d221bd310..34548a0e1bb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -778,6 +778,7 @@ export const layer = Layer.effect( .get(), ) const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) + const stored = !input.model && !ag.model ? model : undefined const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same @@ -785,7 +786,10 @@ export const layer = Layer.effect( .getModel(model.providerID, model.modelID) .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined))) : undefined - const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + const variant = + input.variant ?? + (stored && "variant" in stored && typeof stored.variant === "string" ? stored.variant : undefined) ?? + (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) const info: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9bf1d787d19..01b8b7b5e23 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -784,6 +784,7 @@ export const layer: Layer.Layer< workspaceID: original.workspaceID, title, metadata: structuredClone(original.metadata), + model: original.model ? { ...original.model } : undefined, // kilocode_change - preserve model + variant from the source session sourceID: input.sessionID, // kilocode_change - forks preserve initialized confinement sandboxFallback, // kilocode_change - seed confinement from the source session's original directory }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 1cf2b7c6c28..17a7b39508a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2535,6 +2535,39 @@ it.instance( // Agent variant +noLLMServer.instance( + "preserves the session variant through a model-less handoff", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const source = yield* sessions.create({ + model: { + id: ModelID.make("test-model"), + providerID: ProviderID.make("test"), + variant: "high", + }, + }) + const fork = yield* sessions.fork({ sessionID: source.id }) + const handoff = yield* prompt.prompt({ + sessionID: fork.id, + noReply: true, + parts: [{ type: "text", text: "fork handoff", synthetic: true }], + }) + if (handoff.info.role !== "user") throw new Error("expected user message") + + expect(handoff.info.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), + variant: "high", + }) + + const saved = yield* sessions.get(fork.id) + expect(saved.model?.variant).toBe("high") + }), + { config: cfg }, +) + noLLMServer.instance( "applies agent variant only when using agent model", () => diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 2b958cb682b..2603c774b3a 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -5,6 +5,7 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "@/provider/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -215,4 +216,30 @@ describe("Session", () => { expect(saved.metadata).toBeUndefined() }), ) + + it.instance("fork preserves model and variant", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const model = { + id: ModelID.make("test-model"), + providerID: ProviderID.make("test-provider"), + variant: "high", + } + const created = yield* Effect.acquireRelease( + session.create({ title: "with-model", model }), + (info) => session.remove(info.id).pipe(Effect.ignore), + ) + const saved = yield* session.get(created.id) + expect(saved.model).toEqual(model) + + const fork = yield* Effect.acquireRelease(session.fork({ sessionID: created.id }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + const forked = yield* session.get(fork.id) + + expect(forked.model).toEqual(model) + expect(forked.model?.variant).toBe("high") + expect(forked.model).not.toBe(saved.model) + }), + ) }) From 0a388de3f7842483dc86c6c995f61ab4600fdd8e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 16:07:57 +0200 Subject: [PATCH 2/6] chore(cli): annotate fork variant changes --- packages/opencode/src/session/prompt.ts | 2 ++ packages/opencode/test/session/prompt.test.ts | 2 ++ packages/opencode/test/session/session.test.ts | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 34548a0e1bb..7fb2aafd32c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -778,6 +778,7 @@ export const layer = Layer.effect( .get(), ) const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) + // kilocode_change start - retain the source session variant across Agent Manager's model-less fork handoff const stored = !input.model && !ag.model ? model : undefined const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = @@ -790,6 +791,7 @@ export const layer = Layer.effect( input.variant ?? (stored && "variant" in stored && typeof stored.variant === "string" ? stored.variant : undefined) ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + // kilocode_change end const info: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 17a7b39508a..a536d857291 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2535,6 +2535,7 @@ it.instance( // Agent variant +// kilocode_change start - Agent Manager records a model-less synthetic prompt after forking noLLMServer.instance( "preserves the session variant through a model-less handoff", () => @@ -2567,6 +2568,7 @@ noLLMServer.instance( }), { config: cfg }, ) +// kilocode_change end noLLMServer.instance( "applies agent variant only when using agent model", diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 2603c774b3a..612b1fb1e5d 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -5,7 +5,7 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { ModelID, ProviderID } from "@/provider/schema" +import { ModelID, ProviderID } from "@/provider/schema" // kilocode_change import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -217,6 +217,7 @@ describe("Session", () => { }), ) + // kilocode_change start it.instance("fork preserves model and variant", () => Effect.gen(function* () { const session = yield* SessionNs.Service @@ -242,4 +243,5 @@ describe("Session", () => { expect(forked.model).not.toBe(saved.model) }), ) + // kilocode_change end }) From 7eaa25170d883c5e96c0a1318a2fc1a9dc4c6437 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 18:00:01 +0200 Subject: [PATCH 3/6] fix(cli): preserve historical fork variants --- packages/opencode/src/session/session.ts | 22 +++++++- .../opencode/test/session/session.test.ts | 56 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 01b8b7b5e23..e128166edf4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -778,17 +778,35 @@ export const layer: Layer.Layer< // kilocode_change start - forks into another directory cannot read the source confinement from the new dir, so carry it over explicitly const sandboxFallback = yield* SandboxPolicy.peek(original.directory, input.sessionID) // kilocode_change end + const msgs = yield* messages({ sessionID: input.sessionID }) + // kilocode_change start - historical forks must use the model from retained context, not a later source-session selection + const point = input.messageID + const message = point + ? msgs.findLast((msg) => msg.info.id < point && msg.info.role === "user") + : undefined + const model = + message?.info.role === "user" + ? { + id: ModelID.make(message.info.model.modelID), + providerID: message.info.model.providerID, + variant: message.info.model.variant, + } + : point + ? undefined + : original.model + ? { ...original.model } + : undefined + // kilocode_change end const session = yield* createNext({ directory: ctx.directory, path: sessionPath(ctx.worktree, ctx.directory), workspaceID: original.workspaceID, title, metadata: structuredClone(original.metadata), - model: original.model ? { ...original.model } : undefined, // kilocode_change - preserve model + variant from the source session + model, // kilocode_change - preserve the model + variant active at the fork point sourceID: input.sessionID, // kilocode_change - forks preserve initialized confinement sandboxFallback, // kilocode_change - seed confinement from the source session's original directory }) - const msgs = yield* messages({ sessionID: input.sessionID }) const idMap = new Map() const writer = KiloSession.writer(session.id, sync) // kilocode_change - commit copied transcript in one transaction diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 612b1fb1e5d..1fed371a2fe 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -244,4 +244,60 @@ describe("Session", () => { }), ) // kilocode_change end + + // kilocode_change start + it.instance("historical fork preserves the model at the fork point", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const source = yield* Effect.acquireRelease( + session.create({ + model: { + id: ModelID.make("test-model"), + providerID: ProviderID.make("test-provider"), + variant: "high", + }, + }), + (info) => session.remove(info.id).pipe(Effect.ignore), + ) + yield* session.updateMessage({ + id: MessageID.ascending(), + sessionID: source.id, + role: "user", + time: { created: Date.now() }, + agent: "code", + model: { + providerID: ProviderID.make("test-provider"), + modelID: ModelID.make("test-model"), + variant: "low", + }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + const latest = yield* session.updateMessage({ + id: MessageID.ascending(), + sessionID: source.id, + role: "user", + time: { created: Date.now() }, + agent: "code", + model: { + providerID: ProviderID.make("test-provider"), + modelID: ModelID.make("test-model"), + variant: "high", + }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + const fork = yield* Effect.acquireRelease( + session.fork({ sessionID: source.id, messageID: latest.id }), + (info) => session.remove(info.id).pipe(Effect.ignore), + ) + + expect(fork.model).toEqual({ + id: ModelID.make("test-model"), + providerID: ProviderID.make("test-provider"), + variant: "low", + }) + }), + ) + // kilocode_change end }) From 0062801e824b3975a1fce3702a99e52e27f03dfb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 18:01:10 +0200 Subject: [PATCH 4/6] chore(cli): annotate historical fork lookup --- packages/opencode/src/session/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e128166edf4..0524bce1912 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -778,8 +778,8 @@ export const layer: Layer.Layer< // kilocode_change start - forks into another directory cannot read the source confinement from the new dir, so carry it over explicitly const sandboxFallback = yield* SandboxPolicy.peek(original.directory, input.sessionID) // kilocode_change end - const msgs = yield* messages({ sessionID: input.sessionID }) // kilocode_change start - historical forks must use the model from retained context, not a later source-session selection + const msgs = yield* messages({ sessionID: input.sessionID }) const point = input.messageID const message = point ? msgs.findLast((msg) => msg.info.id < point && msg.info.role === "user") From 08cbafb97373d270b61a24ad34b737baf244d283 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 18:22:33 +0200 Subject: [PATCH 5/6] test(cli): isolate handoff variant regression --- packages/opencode/test/session/prompt.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a536d857291..61e920691f9 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2542,16 +2542,16 @@ noLLMServer.instance( Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service - const source = yield* sessions.create({ + const session = yield* sessions.create({ model: { id: ModelID.make("test-model"), providerID: ProviderID.make("test"), variant: "high", }, }) - const fork = yield* sessions.fork({ sessionID: source.id }) + const handoff = yield* prompt.prompt({ - sessionID: fork.id, + sessionID: session.id, noReply: true, parts: [{ type: "text", text: "fork handoff", synthetic: true }], }) @@ -2563,7 +2563,7 @@ noLLMServer.instance( variant: "high", }) - const saved = yield* sessions.get(fork.id) + const saved = yield* sessions.get(session.id) expect(saved.model?.variant).toBe("high") }), { config: cfg }, From bff6e51f22c4329f96337f3833bdf70a270e277a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 18:51:40 +0200 Subject: [PATCH 6/6] fix(cli): support current model ID types --- packages/opencode/src/session/session.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 8 +++--- .../opencode/test/session/session.test.ts | 26 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 0524bce1912..b9e17798a9f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -787,7 +787,7 @@ export const layer: Layer.Layer< const model = message?.info.role === "user" ? { - id: ModelID.make(message.info.model.modelID), + id: message.info.model.modelID, providerID: message.info.model.providerID, variant: message.info.model.variant, } diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 61e920691f9..f63bd7e9589 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2544,8 +2544,8 @@ noLLMServer.instance( const sessions = yield* Session.Service const session = yield* sessions.create({ model: { - id: ModelID.make("test-model"), - providerID: ProviderID.make("test"), + id: ref.modelID, + providerID: ref.providerID, variant: "high", }, }) @@ -2558,8 +2558,8 @@ noLLMServer.instance( if (handoff.info.role !== "user") throw new Error("expected user message") expect(handoff.info.model).toEqual({ - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ref.providerID, + modelID: ref.modelID, variant: "high", }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 1fed371a2fe..6b219b3765c 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -5,7 +5,7 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { ModelID, ProviderID } from "@/provider/schema" // kilocode_change +type SessionModel = NonNullable // kilocode_change import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -222,10 +222,10 @@ describe("Session", () => { Effect.gen(function* () { const session = yield* SessionNs.Service const model = { - id: ModelID.make("test-model"), - providerID: ProviderID.make("test-provider"), + id: "test-model", + providerID: "test-provider", variant: "high", - } + } as SessionModel const created = yield* Effect.acquireRelease( session.create({ title: "with-model", model }), (info) => session.remove(info.id).pipe(Effect.ignore), @@ -252,10 +252,10 @@ describe("Session", () => { const source = yield* Effect.acquireRelease( session.create({ model: { - id: ModelID.make("test-model"), - providerID: ProviderID.make("test-provider"), + id: "test-model", + providerID: "test-provider", variant: "high", - }, + } as SessionModel, }), (info) => session.remove(info.id).pipe(Effect.ignore), ) @@ -266,8 +266,8 @@ describe("Session", () => { time: { created: Date.now() }, agent: "code", model: { - providerID: ProviderID.make("test-provider"), - modelID: ModelID.make("test-model"), + providerID: source.model!.providerID, + modelID: source.model!.id, variant: "low", }, tools: {}, @@ -280,8 +280,8 @@ describe("Session", () => { time: { created: Date.now() }, agent: "code", model: { - providerID: ProviderID.make("test-provider"), - modelID: ModelID.make("test-model"), + providerID: source.model!.providerID, + modelID: source.model!.id, variant: "high", }, tools: {}, @@ -293,8 +293,8 @@ describe("Session", () => { ) expect(fork.model).toEqual({ - id: ModelID.make("test-model"), - providerID: ProviderID.make("test-provider"), + id: source.model!.id, + providerID: source.model!.providerID, variant: "low", }) }),