mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
fix(cli): switch to the code model after planning (#13112)
* fix(cli): switch to the code model after planning Keep the saved or configured code model when leaving a planning session, even if the catalog lookup fails, so implementation starts on the code model instead of the planning one. Fall back through recent models when no code pick exists, skip handover when its model cannot be resolved, and stamp setAgentModel only on Continue here. * fix(cli): keep plan follow-up off recent-model fallback Preserve the planning model when no saved or configured code pick exists. Look up the handover model once from the compaction agent or the planning model. * fix(cli): restore handover plan-model fallback If the compaction agent's configured model is missing from the catalog, look up the planning model before skipping handover.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Switch to the code model when starting implementation after a planning session.
|
||||
@@ -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<SessionID, AbortController>()
|
||||
@@ -36,8 +33,15 @@ export const PlanFollowupRuntime = {
|
||||
agent(name: string): Promise<Agent.Info | undefined> {
|
||||
return agents().runPromise((svc) => svc.get(name))
|
||||
},
|
||||
model(providerID: ProviderV2.ID, modelID: ModelV2.ID): Promise<Provider.Model> {
|
||||
return providers().runPromise((svc) => svc.getModel(providerID, modelID))
|
||||
async modelIfAvailable(providerID: ProviderV2.ID, modelID: ModelV2.ID): Promise<Provider.Model | undefined> {
|
||||
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<ProviderV2.ID>(Schema.is(ProviderV2.ID)),
|
||||
modelID: z.custom<ModelV2.ID>(Schema.is(ModelV2.ID)),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
variant: z.record(z.string(), z.string().optional()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
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<MessageV2.User, "model">) {
|
||||
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) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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)
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user