diff --git a/electron/kimi.js b/electron/kimi.js index 864970961..c77ffd73d 100644 --- a/electron/kimi.js +++ b/electron/kimi.js @@ -475,7 +475,7 @@ function createStderrThinkingParser() { } /** - * @typedef {{ id: string, name: string, input: string, output: string | null, phase: "start" | "end" | "single", isError: boolean, images?: { mediaType: string, data: string }[] }} ToolEvent + * @typedef {{ id: string, name: string, input: string, output: string | null, phase: "start" | "end" | "single", isError: boolean, images?: { mediaType: string, data: string }[], subagent?: { description: string, agentType: string | null, background: boolean } | null }} ToolEvent */ /** @@ -500,6 +500,34 @@ function outputAndImages(value) { return { output: truncate(text, OUTPUT_TRUNCATE), images: harvested.images }; } +/** + * Subagent fields from kimi's Agent tool args, parsed BEFORE input is + * truncated (a long prompt would cut the JSON). Since 0.24.2 a + * run_in_background launch returns at once and `kimi -p` stays alive + * ("steer" print policy) until the task finishes and steers a new turn — + * stream-json emits no completion event, so the run's exit settles it. + * @param {string} input + * @returns {{ description: string, agentType: string | null, background: boolean } | null} + */ +function agentCallInfo(input) { + let args; + try { + args = JSON.parse(input); + } catch { + return null; + } + if (!args || typeof args !== "object") return null; + return { + description: + typeof args.description === "string" && args.description + ? args.description + : "Agent", + agentType: + typeof args.subagent_type === "string" ? args.subagent_type : null, + background: args.run_in_background === true, + }; +} + /** * All tool events carried by one stream line. * @@ -541,6 +569,7 @@ function extractToolEvents(obj) { output: null, phase: "start", isError: false, + ...(name === "Agent" ? { subagent: agentCallInfo(input) } : {}), }); } return out; diff --git a/electron/runner-provider-kimi.js b/electron/runner-provider-kimi.js index 4c49c6c0b..da92fba6f 100644 --- a/electron/runner-provider-kimi.js +++ b/electron/runner-provider-kimi.js @@ -44,6 +44,8 @@ function createKimiRun(ctx) { lastAssistantText, markRunFailed, appendDoneWorkLog, + addSubagentRow, + setSubagentStatus, } = ctx; /** @@ -111,6 +113,30 @@ function createKimiRun(ctx) { * @type {string | null} */ let capturedKimiSessionId = null; + /** + * Agent(run_in_background) tool ids launched by this run (#175). kimi -p + * stays alive until they finish (steer policy) and stream-json never + * reports a completion, so process exit is the only settle signal. + * @type {Set} + */ + const backgroundSubagents = new Set(); + + /** + * CLI exit ends every agent it was running: "done" on a clean exit, + * "failed" when it died (#1443). stopRun already failed them on Stop. + * @param {"done" | "failed"} status + */ + function settleBackgroundSubagents(status) { + let changed = false; + for (const id of backgroundSubagents) { + changed = setSubagentStatus(threadId, id, status) || changed; + } + backgroundSubagents.clear(); + if (!changed) return; + store.save(); + pushDetail(threadId, null); + pushThreadsChanged(); + } const localCwd = thread.worktreePath || project.path; const binary = resolveBin(providerEntry); @@ -422,6 +448,15 @@ function createKimiRun(ctx) { toolMeta, ); toolMsgById.set(tool.id, msgId); + if (tool.subagent) { + addSubagentRow(threadId, { + id: tool.id, + description: tool.subagent.description, + agentType: tool.subagent.agentType, + status: "running", + }); + if (tool.subagent.background) backgroundSubagents.add(tool.id); + } const notice = guardrailNotice( tool.name, tool.input, @@ -470,6 +505,17 @@ function createKimiRun(ctx) { }); noteToolSpan(threadId, runId, tool.id, tool.name, tool.isError); } + // A background launch acks at once; its row stays running until + // the CLI exits (settleBackgroundSubagents). A failed launch or a + // foreground agent settles here. + if (tool.isError || !backgroundSubagents.has(tool.id)) { + backgroundSubagents.delete(tool.id); + setSubagentStatus( + threadId, + tool.id, + tool.isError ? "failed" : "done", + ); + } } else { // single fire-and-complete const toolMeta = { @@ -500,6 +546,7 @@ function createKimiRun(ctx) { clearTimeout(pushTimer); pushTimer = null; } + settleBackgroundSubagents(code === 0 ? "done" : "failed"); const e = active.get(threadId); if (!e || e.stopping || e.runId !== runId) return; if (e.kind !== "kimi") return; @@ -561,6 +608,7 @@ function createKimiRun(ctx) { clearTimeout(pushTimer); pushTimer = null; } + settleBackgroundSubagents("failed"); const e = active.get(threadId); if (!e || e.stopping || e.runId !== runId) return; if (e.kind !== "kimi") return; diff --git a/electron/runner-subagents.js b/electron/runner-subagents.js index 34eee9f23..1abb6b809 100644 --- a/electron/runner-subagents.js +++ b/electron/runner-subagents.js @@ -223,6 +223,7 @@ function createSubagents(ctx) { } return { + hasRunningSubagent, addSubagentRow, noteCursorSubagent, setSubagentStatus, diff --git a/electron/runner-watchdogs.js b/electron/runner-watchdogs.js index 7191a1056..a7740aad0 100644 --- a/electron/runner-watchdogs.js +++ b/electron/runner-watchdogs.js @@ -33,6 +33,7 @@ function createWatchdogs(ctx) { appendMessage, pushDetail, pushThreadsChanged, + hasRunningSubagent, } = ctx; /** @@ -48,6 +49,9 @@ function createWatchdogs(ctx) { if (thread.status !== "working") continue; if (thread.awaitingInput) continue; if (thread.stalledAt) continue; + // Background subagents run silently in the parent CLI (kimi -p stays + // alive for them, #175); "may be hung, Stop" would kill their results. + if (hasRunningSubagent(thread.id)) continue; const last = thread.lastEventAt ?? thread.runStartedAt ?? now; if (now - last <= stallMs) continue; store.updateThread(thread.id, { stalledAt: now }); diff --git a/electron/runner.js b/electron/runner.js index 133564787..6fff435c8 100644 --- a/electron/runner.js +++ b/electron/runner.js @@ -2637,6 +2637,7 @@ function createRunner(opts) { } = createTurnSetup(ctx); const { + hasRunningSubagent, addSubagentRow, noteCursorSubagent, setSubagentStatus, @@ -2645,6 +2646,7 @@ function createRunner(opts) { finishRunningSubagents, } = createSubagents(ctx); ctx.finishRunningSubagents = finishRunningSubagents; + ctx.hasRunningSubagent = hasRunningSubagent; const { sessionRecorder, diff --git a/electron/test/kimi.test.js b/electron/test/kimi.test.js index 6292f5f70..3ccd3b743 100644 --- a/electron/test/kimi.test.js +++ b/electron/test/kimi.test.js @@ -382,6 +382,45 @@ async function main() { return; } + if (scenario === "background-agent") { + // kimi >= 0.24.2 "steer" print policy (#175): the Agent launch acks at + // once, the process stays alive and silent while the task runs, then the + // completion steers a new main turn on the same stream. + emit({ + role: "assistant", + tool_calls: [ + { + type: "function", + id: "Agent:bg", + function: { + name: "Agent", + arguments: JSON.stringify({ + prompt: "x".repeat(5000), + description: "survey repo", + subagent_type: "explore", + run_in_background: true, + }), + }, + }, + { + type: "function", + id: "Agent:fg", + function: { + name: "Agent", + arguments: JSON.stringify({ prompt: "p", description: "quick look" }), + }, + }, + ], + }); + emit({ role: "tool", tool_call_id: "Agent:bg", content: "task_id: t1 launched" }); + emit({ role: "tool", tool_call_id: "Agent:fg", content: "done" }); + emit({ role: "assistant", content: "launched" }); + await delay(Number(process.env.CODER_FAKE_KIMI_BG_MS) || 600); + emit({ role: "assistant", content: "got: PONG" }); + process.exit(0); + return; + } + process.stderr.write("unknown scenario\\n"); process.exit(1); } @@ -1291,6 +1330,60 @@ describe("kimi runner integration", () => { ); }); + it("background Agent rows stay running, skip the stall sweep, and settle at exit (#175)", async () => { + process.env.CODER_FAKE_KIMI_SCENARIO = "background-agent"; + const prevStall = process.env.CODER_STALL_MS; + process.env.CODER_STALL_MS = "1"; + try { + const thread = store.getThreads()[0]; + await runner.startRun({ threadId: thread.id, prompt: "fan out" }); + const row = (id) => + (store.getThread(thread.id).subagents || []).find((r) => r.id === id); + await waitFor(() => row("Agent:fg")?.status === "done"); + + assert.deepEqual(row("Agent:bg"), { + id: "Agent:bg", + description: "survey repo", + agentType: "explore", + status: "running", + }); + assert.equal(store.getThread(thread.id).status, "working"); + store.updateThread(thread.id, { lastEventAt: Date.now() - 60_000 }); + runner.checkStalls(); + assert.equal(store.getThread(thread.id).stalledAt ?? null, null); + + await waitFor(() => store.getThread(thread.id).status === "done"); + assert.equal(row("Agent:bg").status, "done"); + assert.ok( + store + .getMessages(thread.id) + .some((m) => m.role === "assistant" && /got: PONG/.test(m.text)), + ); + } finally { + if (prevStall === undefined) delete process.env.CODER_STALL_MS; + else process.env.CODER_STALL_MS = prevStall; + } + }); + + it("stopRun settles running background Agent rows (#175)", async () => { + process.env.CODER_FAKE_KIMI_SCENARIO = "background-agent"; + process.env.CODER_FAKE_KIMI_BG_MS = "60000"; + try { + const thread = store.getThreads()[0]; + await runner.startRun({ threadId: thread.id, prompt: "fan out" }); + const row = () => + (store.getThread(thread.id).subagents || []).find( + (r) => r.id === "Agent:bg", + ); + await waitFor(() => row()?.status === "running"); + await runner.stopRun({ threadId: thread.id }); + // Killed work is failed, never done (#1443). + await waitFor(() => row().status === "failed"); + } finally { + delete process.env.CODER_FAKE_KIMI_BG_MS; + } + }); + it("stopRun kills kimi process and leaves idle", async () => { process.env.CODER_FAKE_KIMI_SCENARIO = "slow"; const thread = store.getThreads()[0];