From 8ac629ccc809cda8b5c3668ff57f5f15acc07c50 Mon Sep 17 00:00:00 2001 From: maoxin1234 <875408344@qq.com> Date: Wed, 24 Jun 2026 15:00:47 +0800 Subject: [PATCH 1/4] feat(cli): surface resumable task_id when a subagent stops on error Foreground and background subagent failures now include the child session's task_id and a hint that it can be resumed via the task tool, so a stopped subagent can be continued instead of being lost (#11620). --- .changeset/subagent-resumable-error.md | 5 ++ packages/opencode/src/tool/task.ts | 21 ++++++-- packages/opencode/test/tool/task.test.ts | 68 +++++++++++++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 .changeset/subagent-resumable-error.md diff --git a/.changeset/subagent-resumable-error.md b/.changeset/subagent-resumable-error.md new file mode 100644 index 00000000000..4131bf72d15 --- /dev/null +++ b/.changeset/subagent-resumable-error.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Surface the resumable `task_id` when a subagent stops on an error. Both foreground and background subagent failures now tell the parent agent that the session can be resumed via the task tool with `task_id=""`, so a stopped subagent can be continued instead of being lost. diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index eb7c748f0f3..65e065fbb1a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -84,11 +84,14 @@ function backgroundMessage(input: { input.state === "completed" ? `Background task completed: ${input.description}` : `Background task failed: ${input.description}` + // kilocode_change start - surface the resumable task_id when a background subagent fails (#11620) + const body = input.state === "error" ? `${input.text}\n${resumeHint(input.sessionID)}` : input.text + // kilocode_change end return [ ``, `${title}`, `<${tag}>`, - input.text, + body, // kilocode_change - was input.text ``, "", ].join("\n") @@ -99,6 +102,15 @@ function errorText(error: unknown) { return String(error) } +// kilocode_change start - tell the parent agent how to resume a stopped/failed subagent (#11620) +function resumeHint(sessionID: SessionID) { + return [ + `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, + `and a prompt describing how to continue or recover. Its prior context is preserved.`, + ].join(" ") +} +// kilocode_change end + export const TaskTool = Tool.define( id, Effect.gen(function* () { @@ -255,9 +267,12 @@ export const TaskTool = Tool.define( }, parts, }) - // kilocode_change start - expose terminal child assistant errors through the task tool boundary + // kilocode_change start - expose terminal child assistant errors through the task tool boundary, + // including the resumable task_id so the parent agent can continue the subagent (#11620) if (result.info.role === "assistant" && result.info.error) { - return yield* Effect.fail(new Error(errorMessage(result.info.error))) + return yield* Effect.fail( + new Error(`${errorMessage(result.info.error)}\n${resumeHint(nextSession.id)}`), + ) } // kilocode_change end return result.parts.findLast((item) => item.type === "text")?.text ?? "" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 30048f0ab63..01eb6cf6a1f 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" @@ -510,6 +510,7 @@ describe("tool.task", () => { // kilocode_change start - terminal child assistant errors fail the task tool boundary it.instance("execute fails when child prompt returns assistant error", () => Effect.gen(function* () { + const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() @@ -551,6 +552,71 @@ describe("tool.task", () => { .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) + + // kilocode_change start - the failure surfaces the resumable task_id so the parent can continue the subagent (#11620) + const kids = yield* sessions.children(chat.id) + const childId = kids[0]?.id + expect(childId).toBeDefined() + const squashed = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + const message = squashed instanceof Error ? squashed.message : String(squashed) + expect(message).toContain("child prompt failed") + expect(message).toContain(`task_id="${childId}"`) + expect(message).toContain("can be resumed") + // kilocode_change end + }), + ) + // kilocode_change end + + // kilocode_change start - background subagent failures also surface the resumable task_id (#11620) + background.instance("background task failure injects a resumable task_id into the parent", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const injected: SessionPrompt.PromptInput[] = [] + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(), + prompt: (input) => { + // The parent-session prompt is the injected background result; capture it. + if (input.sessionID === chat.id) { + injected.push(input) + return Effect.succeed(reply(input, "ack")) + } + return Effect.die(new Error("child prompt failed")) + }, + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const childId = result.metadata.sessionId + yield* jobs.wait({ id: childId, timeout: 1_000 }) + + const text = injected + .flatMap((input) => input.parts ?? []) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n") + expect(text).toContain(`state="error"`) + expect(text).toContain(`task_id="${childId}"`) + expect(text).toContain("can be resumed") }), ) // kilocode_change end From 2ac38d7271a35aed231c360fa65a3c23592eb7de Mon Sep 17 00:00:00 2001 From: maoxin1234 <875408344@qq.com> Date: Wed, 24 Jun 2026 16:39:44 +0800 Subject: [PATCH 2/4] test(cli): fix kilocode_change annotations on shared upstream test file --- packages/opencode/test/tool/task.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 01eb6cf6a1f..f25ed72a399 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect } from "bun:test" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause for squashing the failure exit import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" @@ -553,7 +553,7 @@ describe("tool.task", () => { expect(Exit.isFailure(exit)).toBe(true) - // kilocode_change start - the failure surfaces the resumable task_id so the parent can continue the subagent (#11620) + // the failure surfaces the resumable task_id so the parent can continue the subagent (#11620) const kids = yield* sessions.children(chat.id) const childId = kids[0]?.id expect(childId).toBeDefined() @@ -562,7 +562,6 @@ describe("tool.task", () => { expect(message).toContain("child prompt failed") expect(message).toContain(`task_id="${childId}"`) expect(message).toContain("can be resumed") - // kilocode_change end }), ) // kilocode_change end From 0274b726c964a158070858d3cf8f7eac451ebe0e Mon Sep 17 00:00:00 2001 From: maoxin1234 <875408344@qq.com> Date: Wed, 24 Jun 2026 17:18:35 +0800 Subject: [PATCH 3/4] fix(cli): dedupe subagent resume hint and await parent injection in test --- packages/opencode/src/tool/task.ts | 5 ++++- packages/opencode/test/tool/task.test.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 65e065fbb1a..217f21c7042 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -85,7 +85,10 @@ function backgroundMessage(input: { ? `Background task completed: ${input.description}` : `Background task failed: ${input.description}` // kilocode_change start - surface the resumable task_id when a background subagent fails (#11620) - const body = input.state === "error" ? `${input.text}\n${resumeHint(input.sessionID)}` : input.text + const body = + input.state === "error" && !input.text.includes("can be resumed") + ? `${input.text}\n${resumeHint(input.sessionID)}` + : input.text // kilocode_change end return [ ``, diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index f25ed72a399..e376a9bb031 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect } from "bun:test" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause for squashing the failure exit +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause/Deferred for resume-hint coverage import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" @@ -574,6 +574,7 @@ describe("tool.task", () => { const tool = yield* TaskTool const def = yield* tool.init() const injected: SessionPrompt.PromptInput[] = [] + const parentInjected = yield* Deferred.make() const result = yield* def.execute( { @@ -594,7 +595,7 @@ describe("tool.task", () => { // The parent-session prompt is the injected background result; capture it. if (input.sessionID === chat.id) { injected.push(input) - return Effect.succeed(reply(input, "ack")) + return Effect.as(Deferred.succeed(parentInjected, undefined), reply(input, "ack")) } return Effect.die(new Error("child prompt failed")) }, @@ -608,6 +609,8 @@ describe("tool.task", () => { const childId = result.metadata.sessionId yield* jobs.wait({ id: childId, timeout: 1_000 }) + // The parent-session injection is forked asynchronously; wait for it before asserting. + yield* Deferred.await(parentInjected).pipe(Effect.timeout("1 second")) const text = injected .flatMap((input) => input.parts ?? []) From 86f6c58dd295d98324163a3716e858d0d591493c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 29 Jun 2026 19:31:39 +0200 Subject: [PATCH 4/4] fix(cli): keep background subagent resume hint --- packages/opencode/src/tool/task.ts | 5 +++-- packages/opencode/test/tool/task.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 217f21c7042..a4bd51ec26d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -85,9 +85,10 @@ function backgroundMessage(input: { ? `Background task completed: ${input.description}` : `Background task failed: ${input.description}` // kilocode_change start - surface the resumable task_id when a background subagent fails (#11620) + const hint = resumeHint(input.sessionID) const body = - input.state === "error" && !input.text.includes("can be resumed") - ? `${input.text}\n${resumeHint(input.sessionID)}` + input.state === "error" && !input.text.includes(hint) + ? `${input.text}\n${hint}` : input.text // kilocode_change end return [ diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index e376a9bb031..55ebc87c824 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -597,7 +597,7 @@ describe("tool.task", () => { injected.push(input) return Effect.as(Deferred.succeed(parentInjected, undefined), reply(input, "ack")) } - return Effect.die(new Error("child prompt failed")) + return Effect.die(new Error("child prompt failed and can be resumed later")) }, } satisfies TaskPromptOps, },