diff --git a/.changeset/plan-to-code-model-switch.md b/.changeset/plan-to-code-model-switch.md new file mode 100644 index 00000000000..7600aa5d240 --- /dev/null +++ b/.changeset/plan-to-code-model-switch.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Switch to the code model when starting implementation after a planning session. diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 7fd01715ffc..88952c0cefe 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -2,9 +2,9 @@ import { Telemetry } from "@kilocode/kilo-telemetry" import { Agent } from "@/agent/agent" import { TuiEvent } from "@/server/tui-event" import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" import { Identifier } from "@/id/id" import { Instance } from "@/kilocode/instance" +import { KilocodeModelState } from "@/kilocode/config/model-state" import { Provider } from "@/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -17,17 +17,14 @@ import { MessageV2 } from "@/session/message-v2" import { SessionStatus } from "@/session/status" import { Todo } from "@/session/todo" import { makeRuntime } from "@/effect/run-service" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" import { lazy } from "@/util/lazy" -import path from "path" -import z from "zod" import { PlanFile } from "@/kilocode/plan-file" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change const agents = lazy(() => makeRuntime(Agent.Service, AppNodeBuilder.build(Agent.node))) -const providers = lazy(() => makeRuntime(Provider.Service, AppNodeBuilder.build(Provider.node))) const todo = lazy(() => makeRuntime(Todo.Service, Todo.defaultLayer)) const llm = lazy(() => makeRuntime(LLM.Service, AppNodeBuilder.build(LLM.node))) const pending = new Map() @@ -36,8 +33,15 @@ export const PlanFollowupRuntime = { agent(name: string): Promise { return agents().runPromise((svc) => svc.get(name)) }, - model(providerID: ProviderV2.ID, modelID: ModelV2.ID): Promise { - return providers().runPromise((svc) => svc.getModel(providerID, modelID)) + async modelIfAvailable(providerID: ProviderV2.ID, modelID: ModelV2.ID): Promise { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise( + Provider.Service.use((svc) => + svc.getModel(providerID, modelID).pipe( + Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)), + ), + ), + ) }, todo: { get(sessionID: SessionID) { @@ -108,9 +112,15 @@ export async function generateHandover(input: { const log = Log.create({ service: "plan.followup" }) try { const entry = await PlanFollowupRuntime.agent("compaction") - const model = entry?.model - ? await PlanFollowupRuntime.model(entry.model.providerID, entry.model.modelID) - : await PlanFollowupRuntime.model(input.model.providerID, input.model.modelID) + const lookup = async (providerID: ProviderV2.ID, modelID: ModelV2.ID) => + PlanFollowupRuntime.modelIfAvailable(providerID, modelID).catch((err) => { + log.warn("handover model lookup failed", { providerID, modelID, err }) + return undefined + }) + const model = + (entry?.model && (await lookup(entry.model.providerID, entry.model.modelID))) || + (await lookup(input.model.providerID, input.model.modelID)) + if (!model) return "" const sessionID = SessionID.make(Identifier.ascending("session")) const userMsg: MessageV2.User = { @@ -172,54 +182,45 @@ export namespace PlanFollowup { function resolveVariant(value: string | undefined, model: Provider.Model | undefined) { if (!value) return undefined - if (!model?.variants?.[value]) return undefined + if (model && !model.variants?.[value]) return undefined return value } - const ModelState = z - .object({ - model: z - .record( - z.string(), - z.object({ - providerID: z.custom(Schema.is(ProviderV2.ID)), - modelID: z.custom(Schema.is(ModelV2.ID)), - }), - ) - .optional(), - variant: z.record(z.string(), z.string().optional()).optional(), - }) - .passthrough() - - async function resolveCodeModel(input: Pick) { - const state = - Flag.KILO_CLIENT === "cli" - ? await Bun.file(path.join(Global.Path.state, "model.json")) - .text() - .then((raw) => ModelState.safeParse(JSON.parse(raw))) - .then((r) => (r.success ? r.data : undefined)) - .catch(() => undefined) - : undefined - const saved = state?.model?.code - if (saved) { - const full = await PlanFollowupRuntime.model(saved.providerID, saved.modelID).catch(() => undefined) - if (full) { - const key = `${saved.providerID}/${saved.modelID}` - return { - model: { ...saved, variant: resolveVariant(state?.variant?.[key], full) }, - } - } + async function stamp(ref: { providerID: string; modelID: string }, variant?: string) { + const model = { + providerID: ProviderV2.ID.make(ref.providerID), + modelID: ModelV2.ID.make(ref.modelID), + } + try { + const full = await PlanFollowupRuntime.modelIfAvailable(model.providerID, model.modelID) + if (!full) return + return { ...model, variant: resolveVariant(variant, full) } + } catch (err) { + log.warn("code model catalog lookup failed", { + providerID: model.providerID, + modelID: model.modelID, + err, + }) + return { ...model, variant: resolveVariant(variant, undefined) } } + } + + async function pick( + ref: { providerID: string; modelID: string } | undefined, + variant?: string, + ) { + if (!ref) return + return stamp(ref, variant) + } + async function resolveCodeModel(input: Pick) { + const state = Flag.KILO_CLIENT === "cli" ? await KilocodeModelState.get().catch(() => undefined) : undefined + const saved = state?.model.code const entry = await PlanFollowupRuntime.agent("code") - if (entry?.model) { - const full = await PlanFollowupRuntime.model(entry.model.providerID, entry.model.modelID).catch(() => undefined) - if (full) { - return { - model: { ...entry.model, variant: resolveVariant(entry.variant, full) }, - } - } - } + const next = + (await pick(saved, saved && state.variant[`${saved.providerID}/${saved.modelID}`])) ?? + (await pick(entry?.model, entry?.variant)) + if (next) return { model: next } return input } @@ -324,6 +325,7 @@ export namespace PlanFollowup { labelKey: "plan.followup.answer.newSession", description: "Implement in a fresh session with a clean context", descriptionKey: "plan.followup.answer.newSession.description", + mode: "code", }, { label: ANSWER_CONTINUE, @@ -373,20 +375,29 @@ export namespace PlanFollowup { model: MessageV2.User["model"] abort?: AbortSignal }) { - const code = await resolveCodeModel({ - model: input.model, - }) const session = await PlanFollowupRuntime.session((svc) => svc.get(input.sessionID)) const { provide } = await import("@/kilocode/instance") await provide({ directory: session.directory, fn: async () => { + const code = await resolveCodeModel({ + model: input.model, + }) // Create the session FIRST so session.created fires immediately while the // VS Code extension's pendingFollowup gate (30s TTL) is still fresh. The // handover generation below can take tens of seconds and must not block // the SSE event that drives the webview tab switch. - const next = await PlanFollowupRuntime.session((svc) => svc.create({})) + const next = await PlanFollowupRuntime.session((svc) => + svc.create({ + agent: "code", + model: { + id: code.model.modelID, + providerID: code.model.providerID, + variant: code.model.variant ?? "default", + }, + }), + ) const ctl = new AbortController() pending.set(next.id, ctl) const [{ AppRuntime }, { EventV2Bridge }] = await Promise.all([ @@ -560,6 +571,18 @@ export namespace PlanFollowup { model: code.model, text: "Implement the plan above.", }) + await PlanFollowupRuntime.session((svc) => + svc.setAgentModel({ + sessionID: input.sessionID, + agent: "code", + model: { + id: code.model.modelID, + providerID: code.model.providerID, + variant: code.model.variant ?? "default", + }, + time: msg.time.created, + }), + ) KiloSessionPromptQueue.retarget(input.sessionID, msg.id) return "continue" } diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 154956042e0..71e213bc9d3 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -277,15 +277,14 @@ function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) { const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue( (opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any, ) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(text) return { agentSpy, - modelSpy, handoverSpy, [Symbol.dispose]() { agentSpy.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() }, } @@ -364,10 +363,8 @@ describe("plan follow-up", () => { const refineOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_KEEP_REFINING) expect(refineOpt?.mode).toBe("plan") - // Start new session should not carry a mode (it opens a new session — the - // current picker is irrelevant once the session switches). const newOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_NEW_SESSION) - expect(newOpt?.mode).toBeUndefined() + expect(newOpt?.mode).toBe("code") await question.reject(item.id) await expect(pending).resolves.toBe("break") @@ -464,11 +461,11 @@ describe("plan follow-up", () => { } return undefined as any }) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(savedConfigFull) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(savedConfigFull) using _ = { [Symbol.dispose]() { get.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() }, } const seeded = await seed({ text: "1. Build\n2. Test" }) @@ -660,7 +657,7 @@ describe("plan follow-up", () => { }, parts: [], }) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockImplementation( + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation( async (providerID: string, modelID: string) => { if (providerID === saved.providerID && modelID === saved.modelID) return savedConfigFull return fakeModel @@ -672,7 +669,7 @@ describe("plan follow-up", () => { using _mocks = { handoverSpy, [Symbol.dispose]() { - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() }, } @@ -762,7 +759,7 @@ describe("plan follow-up", () => { withInstance(async () => { await using other = await tmpdir({ git: true }) const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async () => undefined as any) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("") const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { @@ -790,7 +787,7 @@ describe("plan follow-up", () => { using _mocks = { [Symbol.dispose]() { get.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() loop.mockRestore() }, @@ -870,7 +867,7 @@ describe("plan follow-up", () => { } return undefined as any }) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockImplementation( + const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation( async (providerID: string, modelID: string) => { if (providerID === saved.providerID && modelID === saved.modelID) return savedFull if (providerID === config.providerID && modelID === config.modelID) return configFull @@ -908,7 +905,67 @@ describe("plan follow-up", () => { expect(user.info.model).toEqual({ ...saved, variant: savedVar }) })) - test("ask - falls back to configured code model when saved CLI code model is unavailable", () => + test("ask - uses saved CLI code model even when catalog lookup fails", () => + withInstance(async () => { + await writeState({ + model: { code: saved }, + variant: { [savedKey]: savedVar }, + }) + const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { + if (name === "code") { + return { + name: "code", + mode: "primary", + permission: [], + options: {}, + model: config, + variant: configVar, + } as any + } + return undefined as any + }) + const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(async () => { + throw new Error("catalog unavailable") + }) + using _ = { + [Symbol.dispose]() { + get.mockRestore() + modelSpy.mockRestore() + }, + } + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + question, + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + await question.reply({ + requestID: item.id, + answers: [[PlanFollowup.ANSWER_CONTINUE]], + }) + + await expect(pending).resolves.toBe("continue") + + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("code") + expect(user.info.model).toEqual({ ...saved, variant: savedVar }) + const current = await store.get(seeded.sessionID) + expect(current.agent).toBe("code") + expect(current.model).toEqual({ + id: saved.modelID, + providerID: saved.providerID, + variant: savedVar, + }) + })) + + test("ask - falls back to configured code model when saved CLI code model is missing", () => withInstance(async () => { await writeState({ model: { code: { providerID: ProviderV2.ID.make("missing"), modelID: ModelV2.ID.make("ghost") } }, @@ -926,9 +983,9 @@ describe("plan follow-up", () => { } return undefined as any }) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockImplementation( + const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation( async (providerID: string, modelID: string) => { - if (providerID === "missing" && modelID === "ghost") throw new Error("missing model") + if (providerID === "missing" && modelID === "ghost") return undefined return configFull }, ) @@ -963,6 +1020,55 @@ describe("plan follow-up", () => { expect(user.info.model).toEqual({ ...config, variant: configVar }) })) + test("ask - uses configured code model even when catalog lookup fails", () => + withInstance(async () => { + const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { + if (name === "code") { + return { + name: "code", + mode: "primary", + permission: [], + options: {}, + model: config, + variant: configVar, + } as any + } + return undefined as any + }) + const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(async () => { + throw new Error("catalog unavailable") + }) + using _ = { + [Symbol.dispose]() { + get.mockRestore() + modelSpy.mockRestore() + }, + } + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + question, + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + await question.reply({ + requestID: item.id, + answers: [[PlanFollowup.ANSWER_CONTINUE]], + }) + + await expect(pending).resolves.toBe("continue") + + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("code") + expect(user.info.model).toEqual({ ...config, variant: configVar }) + })) + test("ask - falls back to planning model when no saved or configured code model exists", () => withInstance(async () => { const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { @@ -999,6 +1105,90 @@ describe("plan follow-up", () => { expect(user.info.model).toEqual({ ...model, variant: planVar }) })) + test("ask - new session uses saved code model even when catalog lookup fails", () => + withInstance(async () => { + await writeState({ + model: { code: saved }, + variant: { [savedKey]: savedVar }, + }) + const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { + if (name === "compaction") return fakeAgent as any + return undefined as any + }) + const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(async () => { + throw new Error("catalog unavailable") + }) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("") + const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ + info: { + id: MessageID.make("msg_test"), + role: "assistant", + sessionID: SessionID.make("ses_test"), + time: { created: Date.now() }, + parentID: MessageID.make("msg_parent"), + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + parts: [], + }) + using _ = { + [Symbol.dispose]() { + get.mockRestore() + modelSpy.mockRestore() + handoverSpy.mockRestore() + loop.mockRestore() + }, + } + const seeded = await seed({ text: "1. Add API\n2. Add tests" }) + const before = await sessions() + const pending = PlanFollowup.ask({ + question, + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + await question.reply({ + requestID: item.id, + answers: [[PlanFollowup.ANSWER_NEW_SESSION]], + }) + + await expect(pending).resolves.toBe("break") + + const after = await sessions() + const prev = new Set(before.map((item) => item.id)) + const added = after.filter((item) => !prev.has(item.id)) + expect(added).toHaveLength(1) + const next = added[0] + if (!next) throw new Error("expected follow-up session") + expect(next.agent).toBe("code") + expect(next.model).toEqual({ + id: saved.modelID, + providerID: saved.providerID, + variant: savedVar, + }) + + const messages = await store.messages({ sessionID: next.id }) + const user = messages.find((item) => item.info.role === "user") + if (!user || user.info.role !== "user") throw new Error("expected user message") + expect(user.info.agent).toBe("code") + expect(user.info.model).toEqual({ ...saved, variant: savedVar }) + })) + test("ask - new session omits handover section when LLM returns empty", () => withInstance(async () => { const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ @@ -1191,7 +1381,7 @@ describe("plan follow-up", () => { const deferred = Promise.withResolvers() const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise.then((text) => { handoverResolvedAt = performance.now() @@ -1224,7 +1414,7 @@ describe("plan follow-up", () => { using _ = { [Symbol.dispose]() { agentSpy.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() loop.mockRestore() unsub() @@ -1279,7 +1469,7 @@ describe("plan follow-up", () => { const deferred = Promise.withResolvers() const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise) const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { @@ -1301,7 +1491,7 @@ describe("plan follow-up", () => { using _ = { [Symbol.dispose]() { agentSpy.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() loop.mockRestore() unsub() @@ -1380,7 +1570,7 @@ describe("plan follow-up", () => { const deferred = Promise.withResolvers() const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise) const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({ info: { @@ -1402,7 +1592,7 @@ describe("plan follow-up", () => { using _ = { [Symbol.dispose]() { agentSpy.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() loop.mockRestore() created() @@ -1539,12 +1729,12 @@ describe("plan follow-up", () => { test("generateHandover - returns empty string on LLM stream failure", () => withInstance(async () => { const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("provider unavailable")) using _ = { [Symbol.dispose]() { agentSpy.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() }, } @@ -1556,12 +1746,12 @@ describe("plan follow-up", () => { test("generateHandover - returns empty string on text stream rejection", () => withInstance(async () => { const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent) - const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel) const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("stream aborted")) using _ = { [Symbol.dispose]() { agentSpy.mockRestore() - modelSpy.mockRestore() + availableSpy.mockRestore() handoverSpy.mockRestore() }, } @@ -1588,4 +1778,52 @@ describe("plan follow-up", () => { expect(result).toBe("## Discoveries\n\nKey finding here") expect(mocks.handoverSpy).toHaveBeenCalledTimes(1) })) + + test("generateHandover - returns empty string when model lookup fails", () => + withInstance(async () => { + const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue({ + ...fakeAgent, + model, + } as any) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockRejectedValue(new Error("catalog unavailable")) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("should not run") + using _ = { + [Symbol.dispose]() { + agentSpy.mockRestore() + availableSpy.mockRestore() + handoverSpy.mockRestore() + }, + } + const seeded = await seed({ text: "1. Build\n2. Test" }) + const result = await generateHandover({ messages: seeded.messages, model }) + expect(result).toBe("") + expect(handoverSpy).not.toHaveBeenCalled() + })) + + test("generateHandover - uses the plan model when the compaction model is missing", () => + withInstance(async () => { + const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue({ + ...fakeAgent, + model: saved, + } as any) + const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation( + async (providerID: string, modelID: string) => { + if (providerID === saved.providerID && modelID === saved.modelID) return undefined + if (providerID === model.providerID && modelID === model.modelID) return fakeModel + return undefined + }, + ) + const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("## Discoveries\n\nFrom plan model") + using _ = { + [Symbol.dispose]() { + agentSpy.mockRestore() + availableSpy.mockRestore() + handoverSpy.mockRestore() + }, + } + const seeded = await seed({ text: "1. Build\n2. Test" }) + const result = await generateHandover({ messages: seeded.messages, model }) + expect(result).toBe("## Discoveries\n\nFrom plan model") + expect(handoverSpy).toHaveBeenCalledTimes(1) + })) })