Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/opencode/src/tool/task.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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* (
Expand Down
60 changes: 59 additions & 1 deletion packages/opencode/test/tool/task.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand DownExpand Up@@ -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()
Expand Down
Loading