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 a2a6714c24e..643cd286db7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -149,26 +149,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 4910b1d41dd..a32c3b8fa61 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -213,10 +213,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 6f2b57f7b88..6aa0efa2f79 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -111,6 +111,30 @@ export namespace PlanFollowup { export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" + 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(() => "") + return plan.trim() + } + async function inject(input: { sessionID: string agent: string @@ -231,7 +255,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, 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 4ab9fb911ab..ca5c8a2c996 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -61,6 +61,19 @@ 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: { messages: MessageV2.WithParts[]; abort: AbortSignal }) { + if (input.abort.aborted) return false + if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false + 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 + const log = Log.create({ service: "session.prompt" }) const state = Instance.state( @@ -346,8 +359,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({ messages: msgs, abort })) { const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort }) if (action === "continue") continue } diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt index 1806e0eba62..8f439ebb6fa 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 988821de3b9..f204dde2886 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 c93a5227682..bd1f8123f33 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 38ac765d2a1..344deb91493 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -117,7 +117,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 00000000000..722e9aea0f8 --- /dev/null +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -0,0 +1,492 @@ +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.", + }, + ], + }) + expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, 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", + }) + expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, 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 + 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({ + 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") + })) + + 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") + })) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0f7ffcec4ac..7ba80294300 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) => {