diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 79bec756026d..245f7859e65c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -45,6 +45,14 @@ export const TaskTool = Tool.define("task", async (ctx) => { description, parameters, async execute(params: z.infer, ctx) { + function getSubagentFailureMessage(result: MessageV2.WithParts) { + const info = result.info + if (info.role !== "assistant" || !info.error) return + if (info.error.name === "MessageAbortedError") return + const detail = info.error.data.message?.trim() + return detail ? `Sub-agent failed: ${detail}` : "Sub-agent failed" + } + const config = await Config.get() // Skip permission check when user explicitly invoked via @ or command subtask @@ -144,6 +152,9 @@ export const TaskTool = Tool.define("task", async (ctx) => { parts: promptParts, }) + const failure = getSubagentFailureMessage(result) + if (failure) throw new Error(failure) + const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" const output = [ diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index df319d8de1e5..bce9bfb318f3 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID } from "../../src/session/schema" +import { SessionPrompt } from "../../src/session/prompt" import { TaskTool } from "../../src/tool/task" import { tmpdir } from "../fixture/fixture" describe("tool.task", () => { + afterEach(() => { + mock.restore() + }) + test("description sorts subagents by name and is stable across calls", async () => { await using tmp = await tmpdir({ config: { @@ -42,4 +50,107 @@ describe("tool.task", () => { }, }) }) + + test("throws when subagent ends with provider error", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + scout: { + mode: "subagent", + description: "Scout agent", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const parent = await Session.create({}) + const parentMessageID = MessageID.ascending() + + await Session.updateMessage({ + id: parentMessageID, + sessionID: parent.id, + role: "assistant", + parentID: MessageID.ascending(), + mode: "build", + agent: "build", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-5.2", + providerID: "openai", + time: { + created: Date.now(), + completed: Date.now(), + }, + } as unknown as MessageV2.Assistant) + + const resolveSpy = spyOn(SessionPrompt, "resolvePromptParts").mockResolvedValue([ + { + type: "text", + text: "subagent prompt", + }, + ]) + + const promptSpy = spyOn(SessionPrompt, "prompt").mockResolvedValue({ + info: { + id: MessageID.ascending(), + sessionID: parent.id, + role: "assistant", + parentID: MessageID.ascending(), + mode: "scout", + agent: "scout", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-5.2", + providerID: "openai", + time: { created: Date.now(), completed: Date.now() }, + error: new MessageV2.APIError({ + message: "You are rate-limited", + isRetryable: false, + statusCode: 429, + }).toObject(), + }, + parts: [], + } as unknown as MessageV2.WithParts) + + const tool = await TaskTool.init() + await expect( + tool.execute( + { + description: "Check provider state", + prompt: "Inspect provider state", + subagent_type: "scout", + }, + { + sessionID: parent.id, + messageID: parentMessageID, + callID: "call-test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + }, + ), + ).rejects.toThrow("Sub-agent failed: You are rate-limited") + + expect(resolveSpy).toHaveBeenCalled() + expect(promptSpy).toHaveBeenCalled() + }, + }) + }) })