diff --git a/packages/opencode/src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json b/packages/opencode/src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json index 2bbf48cfca..6bd887340e 100644 --- a/packages/opencode/src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json +++ b/packages/opencode/src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json @@ -39,6 +39,32 @@ } ] }, + "retrying": { + "prompt": "Preview a background subagent retrying an upstream request.", + "parts": [ + { + "type": "subagent", + "agent": "explore", + "description": "Retry provider request while maintaining context", + "state": "retrying", + "background": true, + "message": "Rate limited by provider; retrying after quota window", + "attempt": 2 + } + ] + }, + "failed": { + "prompt": "Preview a delegated task failure.", + "parts": [ + { + "type": "subagent", + "agent": "general", + "description": "Fail delegated work after checking upstream status", + "state": "error", + "error": "Provider returned an authentication error" + } + ] + }, "completed": { "prompt": "Preview completed foreground and background subagents.", "parts": [ diff --git a/packages/opencode/src/cli/cmd/tui/debug/frame.ts b/packages/opencode/src/cli/cmd/tui/debug/frame.ts index 9225dba134..7bda2ba3b0 100644 --- a/packages/opencode/src/cli/cmd/tui/debug/frame.ts +++ b/packages/opencode/src/cli/cmd/tui/debug/frame.ts @@ -3,6 +3,7 @@ import type { Message, Part, Session, + SessionStatus, TextPart, ToolPart, ToolState, @@ -39,6 +40,19 @@ const PartInput = Schema.Union([ Schema.Struct({ ...SubagentFields, state: Schema.Literal("running") }), Schema.Struct({ ...SubagentFields, state: Schema.Literal("active-background") }), Schema.Struct({ ...SubagentFields, state: Schema.Literal("completed"), background: Schema.optional(Schema.Boolean) }), + Schema.Struct({ + ...SubagentFields, + state: Schema.Literal("retrying"), + background: Schema.optional(Schema.Boolean), + message: Schema.String, + attempt: Schema.Number, + }), + Schema.Struct({ + ...SubagentFields, + state: Schema.Literal("error"), + background: Schema.optional(Schema.Boolean), + error: Schema.String, + }), ]) const Frame = Schema.Struct({ prompt: Schema.String, @@ -66,7 +80,7 @@ export async function createDebugFrameTransport(input: { file: string; frame: st const created = 1_000_000 const root = makeSession(sessionID, input.directory, `${fixture.name}: ${input.frame}`, created) const childSessions = new Map() - const statuses: Record = {} + const statuses: Record = {} const parts = compileParts(frame, sessionID, created, input.directory, childSessions, statuses) const transcript = turn(sessionID, created, frame.prompt, parts) const fetch = createFetch({ root, transcript, childSessions, statuses, directory: input.directory }) @@ -81,7 +95,7 @@ function compileParts( created: number, directory: string, children: Map, - statuses: Record, + statuses: Record, ) { return frame.parts.map((part, index): Part => { const id = `part_${index.toString().padStart(2, "0")}` @@ -125,24 +139,29 @@ function compileParts( ), }) if (part.state === "active-background") statuses[childID] = { type: "busy" } + if (part.state === "retrying") { + statuses[childID] = { type: "retry", attempt: part.attempt, message: part.message, next: created + 1000 } + } + const state = part.state === "completed" ? "completed" : part.state === "error" ? "error" : "running" + const background = + part.state === "active-background" || + ((part.state === "completed" || part.state === "retrying" || part.state === "error") && part.background === true) return tool( sessionID, assistantID(sessionID), id, "task", toolState( - part.state === "running" ? "running" : "completed", + state, { description: part.description, subagent_type: part.agent, }, { sessionId: childID, - ...(part.state === "active-background" || (part.state === "completed" && part.background === true) - ? { background: true } - : {}), + ...(background ? { background: true } : {}), }, - part.description, + part.state === "error" ? part.error : part.description, created, ), ) @@ -153,7 +172,7 @@ function createFetch(input: { root: Session transcript: Transcript childSessions: Map - statuses: Record + statuses: Record directory: string }) { const provider = { @@ -324,7 +343,9 @@ function toolState( created: number, ): ToolState { if (state === "running") return { status: "running", input, metadata, title, time: { start: created } } - if (state === "error") return { status: "error", input, error: title, time: { start: created, end: created + 1 } } + if (state === "error") { + return { status: "error", input, metadata, error: title, time: { start: created, end: created + 1 } } + } return { status: "completed", input, diff --git a/packages/opencode/test/cli/tui/debug-frame.test.ts b/packages/opencode/test/cli/tui/debug-frame.test.ts index 03abc0edb5..be12d574af 100644 --- a/packages/opencode/test/cli/tui/debug-frame.test.ts +++ b/packages/opencode/test/cli/tui/debug-frame.test.ts @@ -47,10 +47,35 @@ describe("TUI debug frames", () => { expect(Object.values(status)).toEqual([{ type: "busy" }]) }) + test("compiles retrying and failed subagent states", async () => { + const retrying = await createDebugFrameTransport({ file: fixture, frame: "retrying", directory: "/tmp/project" }) + const retryTranscript = (await ( + await retrying.fetch(`http://opencode.debug/session/${retrying.sessionID}/message`) + ).json()) as Array<{ parts: Part[] }> + const retryTask = retryTranscript[1]!.parts.find( + (part): part is ToolPart => part.type === "tool" && part.tool === "task", + )! + const retryID = runningMetadata(retryTask).sessionId as string + const status = (await (await retrying.fetch("http://opencode.debug/session/status")).json()) as Record< + string, + { type: string; attempt?: number } + > + const failed = await createDebugFrameTransport({ file: fixture, frame: "failed", directory: "/tmp/project" }) + const failedTranscript = (await ( + await failed.fetch(`http://opencode.debug/session/${failed.sessionID}/message`) + ).json()) as Array<{ parts: Part[] }> + const failedTask = failedTranscript[1]!.parts.find( + (part): part is ToolPart => part.type === "tool" && part.tool === "task", + )! + + expect(status[retryID]).toMatchObject({ type: "retry", attempt: 2 }) + expect(failedTask.state.status).toBe("error") + }) + test("reports available frames for unknown selection", async () => { await expect( createDebugFrameTransport({ file: fixture, frame: "missing", directory: "/tmp/project" }), - ).rejects.toThrow("Available frames: running, active-background, completed") + ).rejects.toThrow("Available frames: running, active-background, retrying, failed, completed") }) test("rejects mutations against static debug frames", async () => { @@ -66,3 +91,8 @@ function completedMetadata(part: ToolPart) { if (part.state.status !== "completed") throw new Error("Expected completed task") return part.state.metadata } + +function runningMetadata(part: ToolPart) { + if (part.state.status !== "running") throw new Error("Expected running task") + return part.state.metadata ?? {} +}