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
14 changes: 13 additions & 1 deletion packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,19 @@ export const TaskTool = Tool.define(
},
parts,
})
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
const textPart = result.parts.findLast((item) => item.type === "text")
const info = result.info.role === "assistant" ? result.info : undefined

const body =
textPart?.text ??
[
"Subagent completed without a text response.",
`finish: ${info?.finish ?? "unknown"}`,
`error: ${info?.error ? JSON.stringify(info.error) : "none"}`,
`parts: ${result.parts.map((part) => part.type).join(", ") || "none"}`,
].join("\n")

return body
})

const resumeWhenIdle: (input: { userID: MessageID; state: "completed" | "error" }) => Effect.Effect<void> =
Expand Down
120 changes: 120 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,126 @@ describe("tool.task", () => {
}),
)

it.instance("execute body is text when subagent returns a text part with content", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const promptOps = stubOps({ text: "the actual result" })

const result = yield* def.execute(
{
description: "some task",
prompt: "do something",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, bypassAgentCheck: true },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

expect(result.output).toContain("the actual result")
}),
)

it.instance("execute body is empty string when subagent returns an intentionally empty text part", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const promptOps = stubOps({ text: "" })

const result = yield* def.execute(
{
description: "some task",
prompt: "do something",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, bypassAgentCheck: true },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

// intentional empty text part → body is empty string, no diagnostic block
expect(result.output).toContain("<task_result>\n\n</task_result>")
expect(result.output).not.toContain("Subagent completed without a text response.")
}),
)

it.instance("execute body is diagnostic block when subagent returns no text part", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()

// promptOps whose prompt returns a WithParts with NO text parts
const promptOps: TaskPromptOps = {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
const id = MessageID.ascending()
return {
info: {
id,
role: "assistant" as const,
parentID: input.messageID ?? MessageID.ascending(),
sessionID: input.sessionID,
mode: input.agent ?? "general",
agent: input.agent ?? "general",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: input.model?.modelID ?? ref.modelID,
providerID: input.model?.providerID ?? ref.providerID,
time: { created: Date.now() },
finish: "tool-calls",
},
parts: [], // no text parts at all
} satisfies MessageV2.WithParts
}),
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, "done")),
}

const result = yield* def.execute(
{
description: "some task",
prompt: "do something",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, bypassAgentCheck: true },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

expect(result.output).toContain("Subagent completed without a text response.")
expect(result.output).toContain("finish: tool-calls")
expect(result.output).toContain("error: none")
expect(result.output).toContain("parts: none")
}),
)

it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
Expand Down
Loading