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
51 changes: 35 additions & 16 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,22 +125,41 @@ export const TaskTool = Tool.define("task", async (ctx) => {
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)

const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
let result: Awaited<ReturnType<typeof SessionPrompt.prompt>>
try {
result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
} catch (error) {
const output = [
`task_id: ${session.id} (subagent session preserved - reuse this id to inspect or retry)`,
"",
"<task_error>",
error instanceof Error ? error.message : String(error),
"</task_error>",
].join("\n")
return {
title: params.description,
metadata: {
sessionId: session.id,
model,
},
output,
}
}

const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""

Expand Down
136 changes: 136 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { TaskTool } from "../../src/tool/task"
import type { Tool } from "../../src/tool/tool"
import * as AgentModule from "../../src/agent/agent"
import * as ConfigModule from "../../src/config/config"
import * as MessageV2Module from "../../src/session/message-v2"
import * as PermissionNextModule from "../../src/permission/next"
import * as SessionModule from "../../src/session"
import * as SessionPromptModule from "../../src/session/prompt"

const ctx: Tool.Context = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}

describe("tool.task", () => {
const restorers: Array<() => void> = []

function remember<T extends { mockRestore(): void }>(spy: T) {
restorers.push(() => spy.mockRestore())
return spy
}

beforeEach(() => {
remember(
spyOn(AgentModule.Agent, "list").mockResolvedValue([
{
name: "general",
mode: "subagent",
description: "General worker",
options: {},
permission: [],
} as any,
]),
)
remember(
spyOn(PermissionNextModule.PermissionNext, "evaluate").mockReturnValue({
action: "allow",
} as any),
)
remember(
spyOn(ConfigModule.Config, "get").mockResolvedValue({
experimental: {
primary_tools: [],
},
} as any),
)
remember(
spyOn(AgentModule.Agent, "get").mockResolvedValue({
name: "general",
mode: "subagent",
description: "General worker",
options: {},
permission: [],
} as any),
)
remember(
spyOn(SessionModule.Session, "create").mockResolvedValue({
id: "task-session-123",
} as any),
)
remember(
spyOn(MessageV2Module.MessageV2, "get").mockResolvedValue({
info: {
role: "assistant",
modelID: "gpt-5",
providerID: "openai",
},
} as any),
)
remember(
spyOn(SessionPromptModule.SessionPrompt, "resolvePromptParts").mockResolvedValue([
{ type: "text", text: "do work" },
] as any),
)
remember(spyOn(SessionPromptModule.SessionPrompt, "cancel").mockImplementation(() => {}))
})

afterEach(() => {
while (restorers.length > 0) {
const restore = restorers.pop()
restore?.()
}
})

test("returns task_id and task_error output when subagent prompt fails", async () => {
remember(
spyOn(SessionPromptModule.SessionPrompt, "prompt").mockRejectedValue(new Error("Tool execution aborted")),
)

const tool = await TaskTool.init()
const result = await tool.execute(
{
description: "Analyze auth flow",
prompt: "inspect auth implementation",
subagent_type: "general",
},
ctx,
)

expect(result.metadata.sessionId).toBe("task-session-123")
expect(result.output).toContain("task_id: task-session-123")
expect(result.output).toContain("<task_error>")
expect(result.output).toContain("Tool execution aborted")
expect(result.output).toContain("</task_error>")
})

test("keeps success path unchanged and still returns task_id", async () => {
remember(
spyOn(SessionPromptModule.SessionPrompt, "prompt").mockResolvedValue({
parts: [{ type: "text", text: "completed" }],
} as any),
)

const tool = await TaskTool.init()
const result = await tool.execute(
{
description: "Analyze auth flow",
prompt: "inspect auth implementation",
subagent_type: "general",
},
ctx,
)

expect(result.metadata.sessionId).toBe("task-session-123")
expect(result.output).toContain("task_id: task-session-123")
expect(result.output).toContain("<task_result>")
expect(result.output).toContain("completed")
expect(result.output).toContain("</task_result>")
})
})