Skip to content
Open
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
11 changes: 11 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ export const TaskTool = Tool.define("task", async (ctx) => {
description,
parameters,
async execute(params: z.infer<typeof parameters>, 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
Expand Down Expand Up @@ -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 = [
Expand Down
113 changes: 112 additions & 1 deletion packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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()
},
})
})
})
Loading