From cc481fbd09dfc787fdfc0f33968d32956a48457e Mon Sep 17 00:00:00 2001 From: replo-interviews Date: Wed, 19 Aug 2026 12:05:16 -0600 Subject: [PATCH] fix(opencode): fail task when child turn ends with an orphaned interrupted tool Co-Authored-By: Claude Fable 5 --- packages/opencode/src/tool/task.ts | 19 +++++++- packages/opencode/test/tool/task.test.ts | 60 +++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b0a866c90e23..93a6bb086234 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -196,7 +196,24 @@ export const TaskTool = Tool.define( agent: next.name, parts, }) - return result.parts.findLast((item) => item.type === "text")?.text ?? "" + const text = result.parts.findLast((item) => item.type === "text")?.text ?? "" + // A severed stream ends the child turn without failing it: cleanup() + // marks the never-executed tool_use error/interrupted and the run loop + // exits, so the child's mid-work narration would otherwise be returned + // as a completed result. Fail the task so the parent knows the work is + // unfinished instead of trusting the truncated text. + const orphan = result.parts.find( + (part): part is SessionV1.ToolPart => + part.type === "tool" && part.state.status === "error" && part.state.metadata?.interrupted === true, + ) + if (orphan) { + return yield* Effect.fail( + new Error( + `Subagent was interrupted mid-turn: its "${orphan.tool}" tool call was cut off before executing, so the task did not finish. Re-run the task to complete it. Partial output before the interruption:\n${text}`, + ), + ) + } + return text }) const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 6238a6a0773f..6c342940771b 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" -import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" @@ -255,6 +255,64 @@ describe("tool.task", () => { }), ) + it.instance("execute fails when the child turn ends with an orphaned interrupted tool", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.sync(() => { + const message = reply(input, "Let me compose the file.") + message.parts.push({ + id: PartID.ascending(), + messageID: message.info.id, + sessionID: input.sessionID, + type: "tool", + callID: "call_orphan", + tool: "write", + state: { + status: "error", + error: "Tool execution aborted", + input: {}, + metadata: { interrupted: true }, + time: { start: Date.now(), end: Date.now() }, + }, + }) + return message + }), + } + + const exit = yield* def + .execute( + { + description: "write file", + prompt: "write the briefing file", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + const rendered = Exit.isFailure(exit) ? Cause.pretty(exit.cause) : "" + expect(rendered).toContain("interrupted mid-turn") + expect(rendered).toContain('"write" tool call') + expect(rendered).toContain("Let me compose the file.") + }), + ) + it.instance("execute asks by default and skips checks when bypassed", () => Effect.gen(function* () { const { chat, assistant } = yield* seed()