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 new file mode 100644 index 000000000000..6bd887340eb4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json @@ -0,0 +1,106 @@ +{ + "name": "subagent-lifecycle", + "frames": { + "running": { + "prompt": "Preview a foreground subagent while it is reading.", + "parts": [ + { "type": "text", "text": "Running delegated work:" }, + { + "type": "subagent", + "agent": "explore", + "description": "Inspect renderer", + "state": "running", + "childTools": [ + { + "tool": "read", + "title": "src/cli/cmd/tui/routes/session/index.tsx", + "input": { "filePath": "src/cli/cmd/tui/routes/session/index.tsx" } + } + ] + } + ] + }, + "active-background": { + "prompt": "Preview a launched background subagent whose child is still reading.", + "parts": [ + { "type": "text", "text": "Active background work:" }, + { + "type": "subagent", + "agent": "explore", + "description": "Inspect renderer", + "state": "active-background", + "childTools": [ + { + "tool": "read", + "title": "src/cli/cmd/tui/routes/session/index.tsx", + "input": { "filePath": "src/cli/cmd/tui/routes/session/index.tsx" } + } + ] + } + ] + }, + "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": [ + { "type": "text", "text": "Completed delegated work:" }, + { + "type": "subagent", + "agent": "general", + "description": "Answer directly", + "state": "completed", + "durationMs": 501 + }, + { + "type": "subagent", + "agent": "general", + "description": "Answer directly", + "background": true, + "state": "completed", + "durationMs": 501 + }, + { + "type": "subagent", + "agent": "explore", + "description": "Inspect renderer", + "background": true, + "state": "completed", + "durationMs": 501, + "childTools": [ + { + "tool": "read", + "title": "src/cli/cmd/tui/routes/session/index.tsx", + "state": "completed", + "input": { "filePath": "src/cli/cmd/tui/routes/session/index.tsx" } + } + ] + } + ] + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/debug/frame.ts b/packages/opencode/src/cli/cmd/tui/debug/frame.ts new file mode 100644 index 000000000000..7bda2ba3b066 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/debug/frame.ts @@ -0,0 +1,361 @@ +import type { + AssistantMessage, + Message, + Part, + Session, + SessionStatus, + TextPart, + ToolPart, + ToolState, + UserMessage, +} from "@opencode-ai/sdk/v2" +import { Schema } from "effect" +import type { EventSource } from "../context/sdk" + +const Data = Schema.Record(Schema.String, Schema.Unknown) +const ChildTool = Schema.Struct({ + tool: Schema.String, + title: Schema.String, + state: Schema.optional(Schema.Literals(["running", "completed", "error"])), + input: Schema.optional(Data), + metadata: Schema.optional(Data), +}) +const SubagentFields = { + type: Schema.Literal("subagent"), + agent: Schema.String, + description: Schema.String, + durationMs: Schema.optional(Schema.Number), + childTools: Schema.optional(Schema.Array(ChildTool)), +} +const PartInput = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ + type: Schema.Literal("tool"), + tool: Schema.String, + title: Schema.String, + state: Schema.optional(Schema.Literals(["running", "completed", "error"])), + input: Schema.optional(Data), + metadata: Schema.optional(Data), + }), + 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, + parts: Schema.Array(PartInput), +}) +const Fixture = Schema.Struct({ + name: Schema.String, + frames: Schema.Record(Schema.String, Frame), +}) +const decodeFixture = Schema.decodeUnknownSync(Schema.fromJsonString(Fixture)) + +type Transcript = Array<{ info: Message; parts: Part[] }> +type FrameInput = typeof Frame.Type + +export async function createDebugFrameTransport(input: { file: string; frame: string; directory: string }) { + const fixture = decodeFixture(await Bun.file(input.file).text()) + const frame = fixture.frames[input.frame] + if (!frame) { + throw new Error( + `Unknown debug frame "${input.frame}" in ${input.file}. Available frames: ${Object.keys(fixture.frames).join(", ")}`, + ) + } + + const sessionID = `ses_debug_${fixture.name.replaceAll(/[^a-zA-Z0-9_]/g, "_")}_${input.frame.replaceAll(/[^a-zA-Z0-9_]/g, "_")}` + const created = 1_000_000 + const root = makeSession(sessionID, input.directory, `${fixture.name}: ${input.frame}`, created) + const childSessions = new Map() + 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 }) + const events: EventSource = { subscribe: async () => () => {} } + + return { sessionID, fetch, events } +} + +function compileParts( + frame: FrameInput, + sessionID: string, + created: number, + directory: string, + children: Map, + statuses: Record, +) { + return frame.parts.map((part, index): Part => { + const id = `part_${index.toString().padStart(2, "0")}` + if (part.type === "text") return text(sessionID, assistantID(sessionID), id, part.text) + if (part.type === "tool") { + return tool( + sessionID, + assistantID(sessionID), + id, + part.tool, + toolState(part.state ?? "completed", part.input ?? {}, part.metadata ?? {}, part.title, created), + ) + } + + const childID = `${sessionID}_child_${index}` + const childTools = part.childTools ?? [] + const complete = part.state === "completed" + children.set(childID, { + session: { ...makeSession(childID, directory, part.description, created), parentID: sessionID }, + transcript: turn( + childID, + created, + part.description, + childTools.map((child, childIndex) => + tool( + childID, + assistantID(childID), + `part_child_${childIndex}`, + child.tool, + toolState( + child.state ?? (complete ? "completed" : "running"), + child.input ?? {}, + child.metadata ?? {}, + child.title, + created, + ), + ), + ), + complete, + part.durationMs ?? 501, + ), + }) + 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( + state, + { + description: part.description, + subagent_type: part.agent, + }, + { + sessionId: childID, + ...(background ? { background: true } : {}), + }, + part.state === "error" ? part.error : part.description, + created, + ), + ) + }) +} + +function createFetch(input: { + root: Session + transcript: Transcript + childSessions: Map + statuses: Record + directory: string +}) { + const provider = { + id: "debug-frame", + name: "Debug Frame", + source: "custom", + env: [], + options: {}, + models: { + preview: { + id: "preview", + providerID: "debug-frame", + api: { id: "preview", url: "", npm: "" }, + name: "Preview", + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 1, output: 1 }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }, + }, + } + return Object.assign( + async (resource: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(resource, init) + const pathname = new URL(request.url).pathname + if (request.method !== "GET") throw new Error(`Unexpected debug frame request: ${request.method} ${pathname}`) + const child = input.childSessions.get(pathname.split("/")[2] ?? "") + if (request.method === "GET" && pathname === `/session/${input.root.id}`) return json(input.root) + if (request.method === "GET" && pathname === `/session/${input.root.id}/message`) return json(input.transcript) + if (request.method === "GET" && child && pathname === `/session/${child.session.id}`) return json(child.session) + if (request.method === "GET" && child && pathname === `/session/${child.session.id}/message`) + return json(child.transcript) + if (request.method === "GET" && (pathname.endsWith("/todo") || pathname.endsWith("/diff"))) return json([]) + switch (pathname) { + case "/agent": + return json([ + { + name: "build", + description: "Internal debug frame", + mode: "primary", + native: true, + permission: [], + model: { providerID: "debug-frame", modelID: "preview" }, + options: {}, + }, + ]) + case "/config/providers": + return json({ providers: [provider], default: { "debug-frame": "preview" } }) + case "/provider": + return json({ all: [], default: { "debug-frame": "preview" }, connected: ["debug-frame"] }) + case "/session": + return json([input.root]) + case "/session/status": + return json(input.statuses) + case "/path": + return json({ home: "", state: "", config: "", worktree: input.directory, directory: input.directory }) + case "/project/current": + return json({ id: "debug-frame" }) + case "/vcs": + return json({ branch: "debug-frame" }) + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + return json({}) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + } + throw new Error(`Unexpected debug frame request: ${request.method} ${pathname}`) + }, + { preconnect: fetch.preconnect }, + ) +} + +function makeSession(id: string, directory: string, title: string, created: number): Session { + return { + id, + slug: id, + projectID: "global", + directory, + title, + version: "debug-frame", + time: { created, updated: created }, + } +} + +function turn( + sessionID: string, + created: number, + prompt: string, + parts: Part[], + complete = true, + durationMs = 501, +): Transcript { + return [ + { info: userMessage(sessionID, created), parts: [text(sessionID, userID(sessionID), "part_user", prompt)] }, + { info: assistantMessage(sessionID, created + 1, complete ? created + durationMs : undefined), parts }, + ] +} + +function userMessage(sessionID: string, created: number): UserMessage { + return { + id: userID(sessionID), + sessionID, + role: "user", + time: { created }, + agent: "build", + model: { providerID: "debug-frame", modelID: "preview" }, + } +} + +function assistantMessage(sessionID: string, created: number, completed?: number): AssistantMessage { + return { + id: assistantID(sessionID), + sessionID, + role: "assistant", + time: { created, ...(completed === undefined ? {} : { completed }) }, + parentID: userID(sessionID), + modelID: "preview", + providerID: "debug-frame", + mode: "build", + agent: "build", + path: { cwd: process.cwd(), root: process.cwd() }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...(completed === undefined ? {} : { finish: "stop" }), + } +} + +function userID(sessionID: string) { + return `msg_${sessionID}_user` +} + +function assistantID(sessionID: string) { + return `msg_${sessionID}_assistant` +} + +function text(sessionID: string, messageID: string, id: string, value: string): TextPart { + return { id, sessionID, messageID, type: "text", text: value } +} + +function tool(sessionID: string, messageID: string, id: string, name: string, state: ToolState): ToolPart { + return { id, sessionID, messageID, type: "tool", callID: `call_${id}`, tool: name, state } +} + +function toolState( + state: "running" | "completed" | "error", + input: Record, + metadata: Record, + title: string, + created: number, +): ToolState { + if (state === "running") return { status: "running", input, metadata, title, time: { start: created } } + if (state === "error") { + return { status: "error", input, metadata, error: title, time: { start: created, end: created + 1 } } + } + return { + status: "completed", + input, + metadata, + output: title, + title, + time: { start: created, end: created + 1 }, + } +} + +function json(value: unknown) { + return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }) +} diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 382147d918f5..8449216f5d2a 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -21,6 +21,7 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "./validate-session" +import { Flag } from "@opencode-ai/core/flag/flag" declare global { const OPENCODE_WORKER_PATH: string @@ -111,6 +112,14 @@ export const TuiThreadCommand = cmd({ .option("agent", { type: "string", describe: "agent to use", + }) + .option("debug-scenario", { + type: "string", + describe: "load an internal TUI debug scenario fixture", + }) + .option("debug-frame", { + type: "string", + describe: "select a named frame from an internal TUI debug scenario", }), handler: async (args) => { // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. @@ -127,10 +136,40 @@ export const TuiThreadCommand = cmd({ return } + const network = resolveNetworkOptionsNoConfig(args) + const external = + process.argv.includes("--port") || + process.argv.includes("--hostname") || + process.argv.includes("--mdns") || + network.mdns || + network.port !== 0 || + network.hostname !== "127.0.0.1" + const debug = Boolean(args.debugScenario || args.debugFrame) + + if (debug && (!args.debugScenario || !args.debugFrame)) { + UI.error("--debug-scenario and --debug-frame must be used together") + process.exitCode = 1 + return + } + + if (debug && !Flag.OPENCODE_PURE) { + UI.error("debug scenarios require --pure") + process.exitCode = 1 + return + } + + if ( + debug && + (external || args.continue || args.session || args.fork || args.prompt || args.model || args.agent) + ) { + UI.error("debug scenarios cannot be combined with network, session, fork, prompt, model, or agent options") + process.exitCode = 1 + return + } + // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. const next = resolveThreadDirectory(args.project) - const file = await target() try { process.chdir(next) } catch { @@ -138,6 +177,77 @@ export const TuiThreadCommand = cmd({ return } const cwd = Filesystem.resolve(process.cwd()) + + const launch = async (input: { + url: string + sessionID?: string + fetch?: typeof fetch + events?: EventSource + prompt?: string + onSnapshot?: () => Promise + stop?: () => Promise + }) => { + try { + try { + await validateSession({ + url: input.url, + sessionID: input.sessionID, + directory: cwd, + fetch: input.fetch, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return false + } + + const config = await TuiConfig.get() + const { createTuiRenderer, tui } = await import("./app") + const renderer = await createTuiRenderer(config) + await tui({ + url: input.url, + renderer, + onSnapshot: input.onSnapshot, + config, + directory: cwd, + fetch: input.fetch, + events: input.events, + args: { + continue: args.continue, + sessionID: input.sessionID, + agent: args.agent, + model: args.model, + prompt: input.prompt, + fork: args.fork, + }, + }).done + return true + } finally { + await input.stop?.() + } + } + + if (args.debugScenario && args.debugFrame) { + const { createDebugFrameTransport } = await import("./debug/frame") + const transport = await createDebugFrameTransport({ + file: Filesystem.resolveFilePath(cwd, args.debugScenario), + frame: args.debugFrame, + directory: cwd, + }) + if ( + !(await launch({ + url: "http://opencode.debug", + sessionID: transport.sessionID, + fetch: transport.fetch, + events: transport.events, + })) + ) + return + process.exit(0) + return + } + + const file = await target() const env = sanitizedProcessEnv({ [OPENCODE_PROCESS_ROLE]: "worker", [OPENCODE_RUN_ID]: ensureRunID(), @@ -187,16 +297,6 @@ export const TuiThreadCommand = cmd({ } const prompt = await input(args.prompt) - const config = await TuiConfig.get() - - const network = resolveNetworkOptionsNoConfig(args) - const external = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - network.mdns || - network.port !== 0 || - network.hostname !== "127.0.0.1" const transport = external ? { @@ -210,51 +310,24 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } - try { - await validateSession({ - url: transport.url, - sessionID: args.session, - directory: cwd, - fetch: transport.fetch, - }) - } catch (error) { - UI.error(errorMessage(error)) - process.exitCode = 1 - return - } - setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) }, 1000).unref?.() - try { - const { createTuiRenderer, tui } = await import("./app") - const renderer = await createTuiRenderer(config) - const handle = tui({ - url: transport.url, - renderer, - async onSnapshot() { + if ( + !(await launch({ + ...transport, + sessionID: args.session, + prompt, + stop, + onSnapshot: async () => { const tui = writeHeapSnapshot("tui.heapsnapshot") const server = await client.call("snapshot", undefined) return [tui, server] }, - config, - directory: cwd, - fetch: transport.fetch, - events: transport.events, - args: { - continue: args.continue, - sessionID: args.session, - agent: args.agent, - model: args.model, - prompt, - fork: args.fork, - }, - }) - await handle.done - } finally { - await stop() - } + })) + ) + return } finally { unguard?.() } diff --git a/packages/opencode/test/cli/tui/debug-frame.test.ts b/packages/opencode/test/cli/tui/debug-frame.test.ts new file mode 100644 index 000000000000..be12d574af24 --- /dev/null +++ b/packages/opencode/test/cli/tui/debug-frame.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { fileURLToPath } from "node:url" +import type { Message, Part, ToolPart } from "@opencode-ai/sdk/v2" +import { createDebugFrameTransport } from "../../../src/cli/cmd/tui/debug/frame" + +const fixture = fileURLToPath( + new URL("../../../src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json", import.meta.url), +) + +describe("TUI debug frames", () => { + test("compiles completed direct and child-tool subagents into distinct sessions", async () => { + const transport = await createDebugFrameTransport({ file: fixture, frame: "completed", directory: "/tmp/project" }) + const transcript = (await ( + await transport.fetch(`http://opencode.debug/session/${transport.sessionID}/message`) + ).json()) as Array<{ info: Message; parts: Part[] }> + const tasks = transcript[1]!.parts.filter((part): part is ToolPart => part.type === "tool" && part.tool === "task") + const directID = completedMetadata(tasks[1]!).sessionId as string + const readID = completedMetadata(tasks[2]!).sessionId as string + const direct = (await ( + await transport.fetch(`http://opencode.debug/session/${directID}/message`) + ).json()) as Array<{ info: Message; parts: Part[] }> + const read = (await (await transport.fetch(`http://opencode.debug/session/${readID}/message`)).json()) as Array<{ + info: Message + parts: Part[] + }> + + expect(directID).not.toBe(readID) + expect(direct[1]!.parts).toHaveLength(0) + expect(read[1]!.parts.filter((part) => part.type === "tool")).toHaveLength(1) + if (direct[1]!.info.role !== "assistant" || direct[1]!.info.time.completed === undefined) { + throw new Error("Expected completed child response") + } + expect(direct[1]!.info.time.completed - direct[0]!.info.time.created).toBe(501) + }) + + test("marks only active background children busy", async () => { + const transport = await createDebugFrameTransport({ + file: fixture, + frame: "active-background", + directory: "/tmp/project", + }) + const status = (await (await transport.fetch("http://opencode.debug/session/status")).json()) as Record< + string, + { type: string } + > + + 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, retrying, failed, completed") + }) + + test("rejects mutations against static debug frames", async () => { + const transport = await createDebugFrameTransport({ file: fixture, frame: "completed", directory: "/tmp/project" }) + + await expect( + transport.fetch(`http://opencode.debug/session/${transport.sessionID}/message`, { method: "POST" }), + ).rejects.toThrow("Unexpected debug frame request: POST") + }) +}) + +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 ?? {} +} diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 53b7488c2682..c5dfbb841467 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" +import { cliIt } from "../../lib/cli-process" import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread" describe("tui thread", () => { @@ -25,4 +27,13 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + cliIt.live("exits nonzero when a requested session ID is invalid", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--session", "invalid", "--pure"]) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("Invalid session ID") + }), + ) })