From a0ba5f4e05c07113905d9c4d020f7b122b358b65 Mon Sep 17 00:00:00 2001 From: onlyfeng Date: Tue, 23 Jun 2026 13:05:54 +0800 Subject: [PATCH] fix(session): preserve failed subtask metadata Preserve failed subtask tool metadata through error transitions so the TUI can navigate into failed subtasks. --- packages/opencode/src/session/processor.ts | 11 ++-- packages/opencode/src/session/prompt.ts | 2 +- .../test/session/prompt-effect.test.ts | 55 +++++++++++-------- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 865e8820e..b2c217621 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -286,17 +286,20 @@ export const layer: Layer.Layer< const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return false - // Agent-recoverable failures (bad args, malformed call, unknown task/actor - // id) carry a marker the TUI reads to render them muted instead of as a red - // error block. The full actionable message still flows to the model. const recoverable = isRecoverableError(error) + const metadata = "metadata" in match.part.state && isRecord(match.part.state.metadata) ? match.part.state.metadata : {} + const errorMetadata = recoverable + ? { ...metadata, recoverable: true } + : Object.keys(metadata).length > 0 + ? metadata + : undefined yield* session.updatePart({ ...match.part, state: { status: "error", input: match.part.state.input, error: errorMessage(error), - ...(recoverable ? { metadata: { ...match.part.state.metadata, recoverable: true } } : {}), + ...(errorMetadata ? { metadata: errorMetadata } : {}), time: { start: match.part.state.time.start, end: Date.now() }, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9d005b532..fde5a6eab 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1090,7 +1090,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the start: part.state.status === "running" ? part.state.time.start : Date.now(), end: Date.now(), }, - metadata: part.state.status === "pending" ? undefined : part.state.metadata, + metadata: "metadata" in part.state ? part.state.metadata : undefined, input: part.state.input, }, } satisfies MessageV2.ToolPart) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 7d118dc1f..01fd5aa85 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -38,6 +38,7 @@ import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool" import { Truncate } from "../../src/tool" +import { Actor } from "../../src/actor/spawn" import { ActorRegistry } from "../../src/actor/registry" import { ActorWaiter } from "../../src/actor/waiter" import { Memory } from "../../src/memory" @@ -162,7 +163,7 @@ const lsp = Layer.succeed( const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -function makeHttp() { +function makeHttp(input?: { actor?: boolean }) { const taskRegistry = ActorRegistry.defaultLayer const deps = Layer.mergeAll( Session.defaultLayer, @@ -225,32 +226,38 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( + const prompt = SessionPrompt.layer.pipe( Layer.provide(Goal.defaultLayer), - Layer.provide(TaskGateState.defaultLayer), - Layer.provide(TaskRegistry.defaultLayer), - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(summary), - Layer.provide(checkpoint), - Layer.provide(team), - Layer.provide(taskRegistry), - Layer.provideMerge(run), - Layer.provideMerge(prune), - Layer.provideMerge(compaction), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(Inbox.defaultLayer), - Layer.provideMerge(deps), - ), - ).pipe(Layer.provide(summary)) + Layer.provide(TaskGateState.defaultLayer), + Layer.provide(TaskRegistry.defaultLayer), + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(summary), + Layer.provide(checkpoint), + Layer.provide(team), + Layer.provide(taskRegistry), + Layer.provideMerge(run), + Layer.provideMerge(prune), + Layer.provideMerge(compaction), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), + Layer.provide(Inbox.defaultLayer), + Layer.provideMerge(deps), + ) + const actor = Actor.layer.pipe( + Layer.provideMerge(prompt), + Layer.provideMerge(taskRegistry), + Layer.provide(TaskRegistry.defaultLayer), + Layer.provideMerge(Inbox.defaultLayer), + ) + if (input?.actor) return Layer.mergeAll(TestLLMServer.layer, prompt, actor).pipe(Layer.provide(summary)) + return Layer.mergeAll(TestLLMServer.layer, prompt).pipe(Layer.provide(summary)) } const it = testEffect(makeHttp()) +const itActor = testEffect(makeHttp({ actor: true })) const unix = process.platform !== "win32" ? it.live : it.live.skip // Config that registers a custom "test" provider with a "test-model" model @@ -593,7 +600,7 @@ it.live("loop continues when finish is stop but assistant has tool parts", () => ), ) -it.live("failed subtask preserves metadata on error tool state", () => +itActor.live("failed subtask preserves metadata on error tool state", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { const prompt = yield* SessionPrompt.Service