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 c5391beac44..0ce90a624af 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -767,6 +767,8 @@ export const layer = Layer.effect( .get() .pipe(Effect.orDie) 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 = !input.variant && ag.variant && same @@ -774,7 +776,11 @@ 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) + // kilocode_change end const info: SessionV1.User = { id: input.messageID ?? MessageID.ascending(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index bf6884316c4..84629226440 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -803,16 +803,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 + // 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") + : undefined + const model = + message?.info.role === "user" + ? { + id: 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, // 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() for (const msg of msgs) { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7ba79ea29bc..56224f6825e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2532,6 +2532,41 @@ 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", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + model: { + id: ref.modelID, + providerID: ref.providerID, + variant: "high", + }, + }) + + const handoff = yield* prompt.prompt({ + sessionID: session.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: ref.providerID, + modelID: ref.modelID, + variant: "high", + }) + + const saved = yield* sessions.get(session.id) + expect(saved.model?.variant).toBe("high") + }), + { 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 b8337b963be..1fe97ea75af 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -8,6 +8,7 @@ import { Session as SessionNs } from "@/session/session" 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" +type SessionModel = NonNullable // kilocode_change import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -248,4 +249,88 @@ describe("Session", () => { expect(saved.metadata).toBeUndefined() }), ) + + // kilocode_change start + it.instance("fork preserves model and variant", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const model = { + 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), + ) + 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) + }), + ) + // 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: "test-model", + providerID: "test-provider", + variant: "high", + } as SessionModel, + }), + (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: source.model!.providerID, + modelID: source.model!.id, + 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: source.model!.providerID, + modelID: source.model!.id, + 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: source.model!.id, + providerID: source.model!.providerID, + variant: "low", + }) + }), + ) + // kilocode_change end })