mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 13:15:26 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5161ccd91 | |||
| 6f340cd701 | |||
| 360a49980e |
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, { session: Session; transcript: Transcript }>()
|
||||
const statuses: Record<string, SessionStatus> = {}
|
||||
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<string, { session: Session; transcript: Transcript }>,
|
||||
statuses: Record<string, SessionStatus>,
|
||||
) {
|
||||
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<string, { session: Session; transcript: Transcript }>
|
||||
statuses: Record<string, SessionStatus>
|
||||
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<string, unknown>,
|
||||
metadata: Record<string, unknown>,
|
||||
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" } })
|
||||
}
|
||||
@@ -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<string[]>
|
||||
stop?: () => Promise<void>
|
||||
}) => {
|
||||
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?.()
|
||||
}
|
||||
|
||||
@@ -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 ?? {}
|
||||
}
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user