From bb73b5a1ae4bc124682b0bc5bd674d1d90e7fcc8 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sat, 16 May 2026 10:54:07 -0400 Subject: [PATCH] feat(task-tool): emit diagnostic when subagent returns no text part (#24447) When a subagent session completes without producing any text part, the parent now receives a structured diagnostic block instead of a silent empty . The block includes the finish reason, any error details, and the list of part types actually produced, making it straightforward to distinguish an intentional empty reply (text part present but empty) from a missing-text-part scenario. Co-Authored-By: Claude Sonnet 4.6 --- packages/opencode/src/tool/task.ts | 14 ++- packages/opencode/test/tool/task.test.ts | 120 +++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index fece68800b06..d38a1128e764 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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 = diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2b7d001572a0..c3c95e53ffd9 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -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("\n\n") + 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