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 ac864f75aa6..2e732cf4b97 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -86,11 +86,18 @@ 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 hint = resumeHint(input.sessionID) + const body = + input.state === "error" && !input.text.includes(hint) + ? `${input.text}\n${hint}` + : input.text + // kilocode_change end return [ ``, `${title}`, `<${tag}>`, - input.text, + body, // kilocode_change - was input.text ``, "", ].join("\n") @@ -101,6 +108,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* () { @@ -263,9 +279,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..55ebc87c824 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, 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" @@ -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,73 @@ describe("tool.task", () => { .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) + + // 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 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 parentInjected = yield* Deferred.make() + + 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.as(Deferred.succeed(parentInjected, undefined), reply(input, "ack")) + } + return Effect.die(new Error("child prompt failed and can be resumed later")) + }, + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + 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 ?? []) + .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