Skip to content
Merged
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
9 changes: 9 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,15 @@ export const TaskTool = Tool.define(
agent: next.name,
parts,
})
if (result.info.role === "assistant" && result.info.error) {
const message =
"message" in result.info.error.data && typeof result.info.error.data.message === "string"
? result.info.error.data.message
: result.info.error.name
return yield* Effect.fail(
new Error(`Subagent failed (task_id: ${nextSession.id}): ${message}`),
)
}
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
})

Expand Down
60 changes: 56 additions & 4 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
Expand Down Expand Up @@ -96,19 +96,27 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
return { chat, assistant }
})

function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
function stubOps(opts?: {
onPrompt?: (input: SessionPrompt.PromptInput) => void
text?: string
error?: NonNullable<SessionV1.Assistant["error"]>
}): TaskPromptOps {
return {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
return reply(input, opts?.text ?? "done", opts?.error)
}),
}
}

function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithParts {
function reply(
input: SessionPrompt.PromptInput,
text: string,
error?: NonNullable<SessionV1.Assistant["error"]>,
): SessionV1.WithParts {
const id = MessageID.ascending()
return {
info: {
Expand All @@ -125,6 +133,7 @@ function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithPa
providerID: input.model?.providerID ?? ref.providerID,
time: { created: Date.now() },
finish: "stop",
error,
},
parts: [
{
Expand Down Expand Up @@ -255,6 +264,49 @@ describe("tool.task", () => {
}),
)

it.instance("execute surfaces child errors with a resumable task_id", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()

const exit = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: stubOps({
text: "",
error: new SessionV1.APIError({ message: "Network connection lost", isRetryable: false }).toObject(),
}),
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) throw new Error("expected task failure")
const child = (yield* sessions.children(chat.id))[0]
expect(child).toBeDefined()
const failure = Cause.squash(exit.cause)
expect(failure).toBeInstanceOf(Error)
if (!(failure instanceof Error)) throw new Error("expected Error defect")
expect(failure.message).toBe(`Subagent failed (task_id: ${child?.id}): Network connection lost`)
}),
)

it.instance("execute asks by default and skips checks when bypassed", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
Expand Down
Loading