From 0886ef8fa069f785d0b0705b55f92c87fb9637ae Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 24 Feb 2026 15:32:58 +0200 Subject: [PATCH 1/2] fix(plan): Show implementation suggestions only when LLM has all the information --- .../cli/cmd/tui/component/prompt/index.tsx | 26 +- .../src/cli/cmd/tui/routes/session/index.tsx | 6 +- .../opencode/src/kilocode/plan-followup.ts | 12 +- packages/opencode/src/session/prompt.ts | 27 +- packages/opencode/src/session/prompt/plan.txt | 2 + packages/opencode/src/tool/plan-exit.txt | 4 +- packages/opencode/src/tool/plan.ts | 53 +-- packages/opencode/src/tool/registry.ts | 3 +- .../test/kilocode/plan-exit-detection.test.ts | 317 ++++++++++++++++++ packages/opencode/test/tool/registry.test.ts | 22 ++ 10 files changed, 398 insertions(+), 74 deletions(-) create mode 100644 packages/opencode/test/kilocode/plan-exit-detection.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d59e683799..8fa530c4b2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -147,26 +147,24 @@ export function Prompt(props: PromptProps) { ), ) - // Initialize agent/model/variant from last user message when session changes - let syncedSessionID: string | undefined + // kilocode_change start - sync local agent/model whenever newest user message changes + let syncedKey: string | undefined createEffect(() => { const sessionID = props.sessionID const msg = lastUserMessage() + if (!sessionID || !msg) return - if (sessionID !== syncedSessionID) { - if (!sessionID || !msg) return + const key = [sessionID, msg.id].join(":") + if (key === syncedKey) return + syncedKey = key - syncedSessionID = sessionID - - // Only set agent if it's a primary agent (not a subagent) - const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) - if (msg.agent && isPrimaryAgent) { - local.agent.set(msg.agent) - if (msg.model) local.model.set(msg.model) - if (msg.variant) local.model.variant.set(msg.variant) - } - } + const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) + if (!msg.agent || !isPrimaryAgent) return + local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + if (msg.variant) local.model.variant.set(msg.variant) }) + // kilocode_change end command.register(() => { return [ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 299d515ed3..809fefe737 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -211,10 +211,8 @@ export function Session() { if (part.state.status !== "completed") return if (part.id === lastSwitch) return - if (part.tool === "plan_exit") { - local.agent.set("build") - lastSwitch = part.id - } else if (part.tool === "plan_enter") { + // kilocode_change - plan_exit no longer switches agent; PlanFollowup handles it + if (part.tool === "plan_enter") { local.agent.set("plan") lastSwitch = part.id } diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 6f2b57f7b8..0fb61f827d 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -111,6 +111,16 @@ export namespace PlanFollowup { export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" + async function resolvePlan(input: { assistant: MessageV2.WithParts; sessionID: string }) { + const text = toText(input.assistant) + if (text) return text + + const session = await Session.get(input.sessionID) + const file = Bun.file(Session.plan(session)) + const plan = await file.text().catch(() => "") + return plan.trim() + } + async function inject(input: { sessionID: string agent: string @@ -231,7 +241,7 @@ export namespace PlanFollowup { const assistant = latest.find((msg) => msg.info.role === "assistant") if (!assistant) return "break" - const plan = toText(assistant) + const plan = await resolvePlan({ assistant, sessionID: input.sessionID }) if (!plan) return "break" const user = latest.find((msg) => msg.info.role === "user")?.info diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c5fa4de1e0..85a0f37675 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -61,6 +61,18 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` export namespace SessionPrompt { + // kilocode_change start - share follow-up trigger logic with tests + export function shouldAskPlanFollowup(input: { + assistant: MessageV2.WithParts | undefined + abort: AbortSignal + }) { + if (input.abort.aborted) return false + if (!input.assistant) return false + if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false + return input.assistant.parts.some((p) => p.type === "tool" && p.tool === "plan_exit" && p.state.status === "completed") + } + // kilocode_change end + const log = Log.create({ service: "session.prompt" }) const state = Instance.state( @@ -325,12 +337,16 @@ export namespace SessionPrompt { let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined + let lastAssistantMsg: MessageV2.WithParts | undefined // kilocode_change - capture full msg for plan_exit detection let lastFinished: MessageV2.Assistant | undefined let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant + if (!lastAssistant && msg.info.role === "assistant") { + lastAssistant = msg.info as MessageV2.Assistant + lastAssistantMsg = msg // kilocode_change + } if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break @@ -346,8 +362,8 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { - // kilocode_change start - ask follow-up after plan agent completes - if (lastUser.agent === "plan" && !abort.aborted && ["cli", "vscode"].includes(Flag.KILO_CLIENT)) { + // kilocode_change start - ask follow-up when plan_exit tool was called + if (shouldAskPlanFollowup({ assistant: lastAssistantMsg, abort })) { const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort }) if (action === "continue") continue } @@ -676,7 +692,10 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) // Build system prompt, adding structured output instruction if needed - const system = [...(await SystemPrompt.environment(model, lastUser.editorContext)), ...(await InstructionPrompt.system())] // kilocode_change + const system = [ + ...(await SystemPrompt.environment(model, lastUser.editorContext)), + ...(await InstructionPrompt.system()), + ] // kilocode_change const format = lastUser.format ?? { type: "text" } if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt index 1806e0eba6..8f439ebb6f 100644 --- a/packages/opencode/src/session/prompt/plan.txt +++ b/packages/opencode/src/session/prompt/plan.txt @@ -23,4 +23,6 @@ Ask the user clarifying questions or ask for their opinion when weighing tradeof ## Important The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. + +When you have finalized your plan and are confident it is ready for implementation, call the plan_exit tool to signal completion. Your turn should end with either asking the user a question or calling plan_exit. diff --git a/packages/opencode/src/tool/plan-exit.txt b/packages/opencode/src/tool/plan-exit.txt index 988821de3b..f204dde288 100644 --- a/packages/opencode/src/tool/plan-exit.txt +++ b/packages/opencode/src/tool/plan-exit.txt @@ -1,6 +1,6 @@ -Use this tool when you have completed the planning phase and are ready to exit plan agent. +Signal that planning is complete and the plan is ready for implementation. -This tool will ask the user if they want to switch to build agent to start implementing the plan. +Call this tool once you have finalized the plan file and are confident it is ready. This ends your planning turn and hands control back to the user. Call this tool: - After you have written a complete plan to the plan file diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index c93a522768..bd1f8123f3 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -17,64 +17,21 @@ async function getLastModel(sessionID: string) { return Provider.defaultModel() } +// kilocode_change start - simplified plan_exit: readiness signal only, no user prompt export const PlanExitTool = Tool.define("plan_exit", { description: EXIT_DESCRIPTION, parameters: z.object({}), async execute(_params, ctx) { const session = await Session.get(ctx.sessionID) const plan = path.relative(Instance.worktree, Session.plan(session)) - const answers = await Question.ask({ - sessionID: ctx.sessionID, - questions: [ - { - // kilocode_change start - question: `Plan at ${plan} is complete. Would you like to switch to the code agent and start implementing?`, - header: "Code Agent", - custom: false, - options: [ - { label: "Yes", description: "Switch to code agent and start implementing the plan" }, - { label: "No", description: "Stay with plan agent to continue refining the plan" }, - ], - // kilocode_change end - }, - ], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) - - const answer = answers[0]?.[0] - if (answer === "No") throw new Question.RejectedError() - - const model = await getLastModel(ctx.sessionID) - - const userMsg: MessageV2.User = { - id: Identifier.ascending("message"), - sessionID: ctx.sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: "code", // kilocode_change - renamed from "build" to "code" - model, - } - await Session.updateMessage(userMsg) - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: userMsg.id, - sessionID: ctx.sessionID, - type: "text", - text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, - synthetic: true, - } satisfies MessageV2.TextPart) - - // kilocode_change start return { - title: "Switching to code agent", - output: "User approved switching to code agent. Wait for further instructions.", - metadata: {}, + title: "Planning complete", + output: `Plan is ready at ${plan}. Ending planning turn.`, + metadata: { plan }, } - // kilocode_change end }, }) +// kilocode_change end export const PlanEnterTool = Tool.define("plan_enter", { description: ENTER_DESCRIPTION, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5d61cfa48a..1be8131221 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -114,7 +114,8 @@ export namespace ToolRegistry { ApplyPatchTool, ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), - ...(Flag.KILO_EXPERIMENTAL_PLAN_MODE && Flag.KILO_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), + PlanExitTool, // kilocode_change - always registered; gated by agent permission instead + ...(Flag.KILO_EXPERIMENTAL_PLAN_MODE && Flag.KILO_CLIENT === "cli" ? [PlanEnterTool] : []), ...custom, ] } diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts new file mode 100644 index 0000000000..eb06137be2 --- /dev/null +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Identifier } from "../../src/id/id" +import { Instance } from "../../src/project/instance" +import { PlanFollowup } from "../../src/kilocode/plan-followup" +import { Question } from "../../src/question" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const model = { + providerID: "openai", + modelID: "gpt-4", +} + +async function withInstance(fn: () => Promise) { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ directory: tmp.path, fn }) +} + +async function seed(input: { + text?: string + agent?: string + tools?: Array<{ tool: string; input: Record; output: string }> + finish?: string +}) { + const session = await Session.create({}) + const user = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + time: { created: Date.now() }, + agent: input.agent ?? "plan", + model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "Create a plan", + }) + + const assistant: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: Date.now() }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: input.agent ?? "plan", + agent: input.agent ?? "plan", + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + finish: (input.finish as MessageV2.Assistant["finish"]) ?? "end_turn", + } + await Session.updateMessage(assistant) + if (input.text !== undefined) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant.id, + sessionID: session.id, + type: "text", + text: input.text, + }) + } + + for (const t of input.tools ?? []) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: Identifier.ascending("tool"), + tool: t.tool, + state: { + status: "completed", + input: t.input, + output: t.output, + title: t.tool, + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + } satisfies MessageV2.ToolPart) + } + + const messages = await Session.messages({ sessionID: session.id }) + return { sessionID: session.id, messages } +} + +async function waitQuestion(sessionID: string) { + for (let i = 0; i < 50; i++) { + const list = await Question.list() + const question = list.find((item) => item.sessionID === sessionID) + if (question) return question + await Bun.sleep(10) + } +} + +describe("plan_exit detection", () => { + test("PlanFollowup.ask triggers when plan_exit tool is present", () => + withInstance(async () => { + const seeded = await seed({ + text: "Here is the plan", + tools: [ + { + tool: "plan_exit", + input: {}, + output: "Plan is ready at .opencode/plans/plan.md. Ending planning turn.", + }, + ], + }) + const assistant = seeded.messages + .slice() + .reverse() + .find((msg) => msg.info.role === "assistant") + expect(SessionPrompt.shouldAskPlanFollowup({ assistant, abort: AbortSignal.any([]) })).toBe(true) + + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const question = await waitQuestion(seeded.sessionID) + expect(question).toBeDefined() + if (!question) return + expect(question.questions[0].header).toBe("Implement") + await Question.reject(question.id) + await expect(pending).resolves.toBe("break") + })) + + test("PlanFollowup.ask triggers and continue works with plan_exit", () => + withInstance(async () => { + const seeded = await seed({ + text: "Here is the plan", + tools: [ + { + tool: "plan_exit", + input: {}, + output: "Plan is ready. Ending planning turn.", + }, + ], + }) + + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const question = await waitQuestion(seeded.sessionID) + expect(question).toBeDefined() + if (!question) return + await Question.reply({ + requestID: question.id, + answers: [[PlanFollowup.ANSWER_CONTINUE]], + }) + + await expect(pending).resolves.toBe("continue") + + const messages = await Session.messages({ sessionID: seeded.sessionID }) + const user = messages + .slice() + .reverse() + .find((m) => m.info.role === "user") + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("code") + })) + + test("plan agent completion without plan_exit does NOT trigger PlanFollowup", () => + withInstance(async () => { + const seeded = await seed({ + text: "Here is a partial plan, I have questions", + }) + const assistant = seeded.messages + .slice() + .reverse() + .find((msg) => msg.info.role === "assistant") + expect(SessionPrompt.shouldAskPlanFollowup({ assistant, abort: AbortSignal.any([]) })).toBe(false) + const list = await Question.list() + expect(list).toHaveLength(0) + })) + + test("plan_exit with non-completed status does NOT trigger", () => + withInstance(async () => { + const session = await Session.create({}) + const user = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + time: { created: Date.now() }, + agent: "plan", + model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "Create a plan", + }) + + const assistant: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: Date.now() }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: "plan", + agent: "plan", + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + finish: "end_turn", + } + await Session.updateMessage(assistant) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant.id, + sessionID: session.id, + type: "text", + text: "Here is the plan", + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: Identifier.ascending("tool"), + tool: "plan_exit", + state: { + status: "error", + error: "Something went wrong", + time: { start: Date.now(), end: Date.now() }, + metadata: {}, + input: {}, + }, + } satisfies MessageV2.ToolPart) + + const messages = await Session.messages({ sessionID: session.id }) + + // Verify the tool part IS present but errored (not completed) + const toolPart = messages.flatMap((msg) => msg.parts).find((p) => p.type === "tool" && p.tool === "plan_exit") + expect(toolPart).toBeDefined() + expect(toolPart!.type === "tool" && toolPart!.state.status).toBe("error") + + // Use the shared predicate — errored plan_exit should not trigger + const assistantMsg = messages + .slice() + .reverse() + .find((msg) => msg.info.role === "assistant") + expect(SessionPrompt.shouldAskPlanFollowup({ assistant: assistantMsg, abort: AbortSignal.any([]) })).toBe(false) + + // Confirm no questions were posted + const list = await Question.list() + expect(list).toHaveLength(0) + })) + + test("PlanFollowup.ask falls back to plan file for tool-only plan_exit turns", () => + withInstance(async () => { + const seeded = await seed({ + tools: [ + { + tool: "plan_exit", + input: {}, + output: "Plan is ready. Ending planning turn.", + }, + ], + }) + + const session = await Session.get(seeded.sessionID) + const plan = Session.plan(session) + await fs.mkdir(path.dirname(plan), { recursive: true }) + await Bun.write(plan, "Do implementation step 1") + + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const question = await waitQuestion(seeded.sessionID) + expect(question).toBeDefined() + if (!question) return + await Question.reply({ + requestID: question.id, + answers: [[PlanFollowup.ANSWER_CONTINUE]], + }) + await expect(pending).resolves.toBe("continue") + })) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0f7ffcec4a..7ba8029430 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -6,6 +6,28 @@ import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" describe("tool.registry", () => { + // kilocode_change start - plan_exit is always registered + test("plan_exit is always registered regardless of client", async () => { + const original = process.env["KILO_CLIENT"] + try { + for (const client of ["cli", "vscode", "desktop", "app"]) { + process.env["KILO_CLIENT"] = client + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("plan_exit") + }, + }) + } + } finally { + if (original === undefined) delete process.env["KILO_CLIENT"] + else process.env["KILO_CLIENT"] = original + } + }) + // kilocode_change end + test("loads tools from .opencode/tool (singular)", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 7143683e20cec0d2d7f084dd9875225fc9ff14a2 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Feb 2026 09:46:17 +0200 Subject: [PATCH 2/2] fix: Fix plan_exit call --- .../opencode/src/kilocode/plan-followup.ts | 20 +- packages/opencode/src/session/prompt.ts | 21 +- .../test/kilocode/plan-exit-detection.test.ts | 205 ++++++++++++++++-- 3 files changed, 216 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 0fb61f827d..6aa0efa2f7 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -111,10 +111,24 @@ export namespace PlanFollowup { export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" - async function resolvePlan(input: { assistant: MessageV2.WithParts; sessionID: string }) { - const text = toText(input.assistant) + async function resolvePlan(input: { assistant?: MessageV2.WithParts; messages: MessageV2.WithParts[]; sessionID: string }) { + // Fast path: check the last assistant message's text first (avoids array scanning) + if (input.assistant) { + const text = toText(input.assistant) + if (text) return text + } + + // Fallback: scan all assistant messages after the last user message (handles + // cases where plan text is on an earlier assistant and the last one is empty) + const lastUserIdx = input.messages.findLastIndex((m) => m.info.role === "user") + const assistantMessages = input.messages + .slice(lastUserIdx + 1) + .filter((m) => m.info.role === "assistant") + + const text = assistantMessages.map(toText).filter(Boolean).join("\n\n").trim() if (text) return text + // Fall back to plan file on disk const session = await Session.get(input.sessionID) const file = Bun.file(Session.plan(session)) const plan = await file.text().catch(() => "") @@ -241,7 +255,7 @@ export namespace PlanFollowup { const assistant = latest.find((msg) => msg.info.role === "assistant") if (!assistant) return "break" - const plan = await resolvePlan({ assistant, sessionID: input.sessionID }) + const plan = await resolvePlan({ assistant, messages: input.messages, sessionID: input.sessionID }) if (!plan) return "break" const user = latest.find((msg) => msg.info.role === "user")?.info diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 85a0f37675..d8e6ea9052 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -62,14 +62,15 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { // kilocode_change start - share follow-up trigger logic with tests - export function shouldAskPlanFollowup(input: { - assistant: MessageV2.WithParts | undefined - abort: AbortSignal - }) { + export function shouldAskPlanFollowup(input: { messages: MessageV2.WithParts[]; abort: AbortSignal }) { if (input.abort.aborted) return false - if (!input.assistant) return false if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false - return input.assistant.parts.some((p) => p.type === "tool" && p.tool === "plan_exit" && p.state.status === "completed") + const lastUserIdx = input.messages.findLastIndex((m) => m.info.role === "user") + return input.messages + .slice(lastUserIdx + 1) + .some((msg) => + msg.parts.some((p) => p.type === "tool" && p.tool === "plan_exit" && p.state.status === "completed"), + ) } // kilocode_change end @@ -337,16 +338,12 @@ export namespace SessionPrompt { let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined - let lastAssistantMsg: MessageV2.WithParts | undefined // kilocode_change - capture full msg for plan_exit detection let lastFinished: MessageV2.Assistant | undefined let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User - if (!lastAssistant && msg.info.role === "assistant") { - lastAssistant = msg.info as MessageV2.Assistant - lastAssistantMsg = msg // kilocode_change - } + if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break @@ -363,7 +360,7 @@ export namespace SessionPrompt { lastUser.id < lastAssistant.id ) { // kilocode_change start - ask follow-up when plan_exit tool was called - if (shouldAskPlanFollowup({ assistant: lastAssistantMsg, abort })) { + if (shouldAskPlanFollowup({ messages: msgs, abort })) { const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort }) if (action === "continue") continue } diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index eb06137be2..722e9aea0f 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -126,11 +126,7 @@ describe("plan_exit detection", () => { }, ], }) - const assistant = seeded.messages - .slice() - .reverse() - .find((msg) => msg.info.role === "assistant") - expect(SessionPrompt.shouldAskPlanFollowup({ assistant, abort: AbortSignal.any([]) })).toBe(true) + expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true) const pending = PlanFollowup.ask({ sessionID: seeded.sessionID, @@ -190,11 +186,7 @@ describe("plan_exit detection", () => { const seeded = await seed({ text: "Here is a partial plan, I have questions", }) - const assistant = seeded.messages - .slice() - .reverse() - .find((msg) => msg.info.role === "assistant") - expect(SessionPrompt.shouldAskPlanFollowup({ assistant, abort: AbortSignal.any([]) })).toBe(false) + expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(false) const list = await Question.list() expect(list).toHaveLength(0) })) @@ -271,17 +263,97 @@ describe("plan_exit detection", () => { expect(toolPart!.type === "tool" && toolPart!.state.status).toBe("error") // Use the shared predicate — errored plan_exit should not trigger - const assistantMsg = messages - .slice() - .reverse() - .find((msg) => msg.info.role === "assistant") - expect(SessionPrompt.shouldAskPlanFollowup({ assistant: assistantMsg, abort: AbortSignal.any([]) })).toBe(false) + expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false) // Confirm no questions were posted const list = await Question.list() expect(list).toHaveLength(0) })) + test("plan_exit on earlier assistant message triggers when later message has text only", () => + withInstance(async () => { + const session = await Session.create({}) + // Use explicit timestamps to ensure deterministic message ordering + const now = Date.now() + const user = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + time: { created: now }, + agent: "plan", + model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "Create a plan", + }) + + // First assistant message: has plan_exit tool, finish = tool-calls + const assistant1: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: now + 1 }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: "plan", + agent: "plan", + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "tool-calls", + } + await Session.updateMessage(assistant1) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant1.id, + sessionID: session.id, + type: "tool", + callID: Identifier.ascending("tool"), + tool: "plan_exit", + state: { + status: "completed", + input: {}, + output: "Plan is ready. Ending planning turn.", + title: "plan_exit", + metadata: {}, + time: { start: now + 1, end: now + 1 }, + }, + } satisfies MessageV2.ToolPart) + + // Second assistant message: text only, finish = end_turn (this is what lastAssistantMsg would point to) + const assistant2: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: now + 2 }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: "plan", + agent: "plan", + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "end_turn", + } + await Session.updateMessage(assistant2) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant2.id, + sessionID: session.id, + type: "text", + text: "The plan is complete. I've called plan_exit.", + }) + + const messages = await Session.messages({ sessionID: session.id }) + expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true) + })) + test("PlanFollowup.ask falls back to plan file for tool-only plan_exit turns", () => withInstance(async () => { const seeded = await seed({ @@ -314,4 +386,107 @@ describe("plan_exit detection", () => { }) await expect(pending).resolves.toBe("continue") })) + + test("PlanFollowup.ask shows prompt when plan text is on earlier assistant and last assistant is empty", () => + withInstance(async () => { + const session = await Session.create({}) + // Use explicit timestamps to ensure deterministic message ordering + const now = Date.now() + const user = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + time: { created: now }, + agent: "plan", + model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "Create a plan", + }) + + // First assistant message: has plan text + plan_exit tool + const assistant1: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: now + 1 }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: "plan", + agent: "plan", + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "tool-calls", + } + await Session.updateMessage(assistant1) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant1.id, + sessionID: session.id, + type: "text", + text: "Here is the detailed plan:\n\n## Step 1\nDo something\n\n## Step 2\nDo something else", + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistant1.id, + sessionID: session.id, + type: "tool", + callID: Identifier.ascending("tool"), + tool: "plan_exit", + state: { + status: "completed", + input: {}, + output: "Plan is ready. Ending planning turn.", + title: "plan_exit", + metadata: {}, + time: { start: now + 1, end: now + 1 }, + }, + } satisfies MessageV2.ToolPart) + + // Second assistant message: empty (LLM follow-up after tool result) + const assistant2: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: now + 2 }, + parentID: user.id, + modelID: model.modelID, + providerID: model.providerID, + mode: "plan", + agent: "plan", + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "end_turn", + } + await Session.updateMessage(assistant2) + + const messages = await Session.messages({ sessionID: session.id }) + + // shouldAskPlanFollowup should detect plan_exit on the earlier message + expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true) + + // PlanFollowup.ask should find plan text from the earlier assistant and show prompt + const pending = PlanFollowup.ask({ + sessionID: session.id, + messages, + abort: AbortSignal.any([]), + }) + + const question = await waitQuestion(session.id) + expect(question).toBeDefined() + if (!question) return + expect(question.questions[0].header).toBe("Implement") + await Question.reply({ + requestID: question.id, + answers: [[PlanFollowup.ANSWER_CONTINUE]], + }) + await expect(pending).resolves.toBe("continue") + })) })