diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 80f38d287c..37cafc7510 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -311,7 +311,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) => raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) }) +type Endpoint6_1Request = Parameters[0] +type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] } +const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) => + raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) }) type Endpoint7_0Request = Parameters[0] type Endpoint7_0Input = { diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index d1e7225bed..2e1180181e 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -53,6 +53,8 @@ import type { MessageListOutput, ModelListInput, ModelListOutput, + ModelDefaultInput, + ModelDefaultOutput, GenerateTextInput, GenerateTextOutput, ProviderListInput, @@ -627,6 +629,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/model/default`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), }, generate: { text: (input: GenerateTextInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 9783526c82..df2b91f513 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2217,6 +2217,67 @@ export type ModelListOutput = { }> } +export type ModelDefaultInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ModelDefaultOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly providerID: string + readonly family?: string + readonly name: string + readonly api: + | { + readonly id: string + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { + readonly id: string + readonly type: "native" + readonly url?: string + readonly settings: { readonly [x: string]: JsonValue } + } + readonly capabilities: { + readonly tools: boolean + readonly input: ReadonlyArray + readonly output: ReadonlyArray + } + readonly request: { + readonly settings: { readonly [x: string]: JsonValue } + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + readonly variant?: string + } + readonly variants: ReadonlyArray<{ + readonly id: string + readonly settings: { readonly [x: string]: JsonValue } + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + }> + readonly time: { readonly released: number } + readonly cost: ReadonlyArray<{ + readonly tier?: { readonly type: "context"; readonly size: number } + readonly input: number + readonly output: number + readonly cache: { readonly read: number; readonly write: number } + }> + readonly status: "alpha" | "beta" | "deprecated" | "active" + readonly enabled: boolean + readonly limit: { readonly context: number; readonly input?: number; readonly output: number } + } | null +} + export type GenerateTextInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -3533,6 +3594,19 @@ export type EventSubscribeOutput = readonly delivery: "steer" | "queue" } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.execution.settled" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly outcome: "success" | "failure" | "interrupted" + readonly error?: { readonly type: "unknown"; readonly message: string } + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index e12c24162c..9ee8f5f739 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -139,6 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.next.prompt.admitted": () => Effect.void, + "session.next.execution.settled": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index f3f64fe266..181ba5a972 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -8,7 +8,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" +import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -387,7 +387,7 @@ const layer = Layer.effect( ) }) - const run = Effect.fn("SessionRunner.run")(function* (input: { + const drain = Effect.fnUntraced(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) { @@ -418,6 +418,33 @@ const layer = Layer.effect( } }) + const run = Effect.fn("SessionRunner.run")( + (input: { readonly sessionID: SessionSchema.ID; readonly force: boolean }) => + drain(input).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + const failure = + Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined + yield* events.publish( + SessionEvent.ExecutionSettled, + { + sessionID: input.sessionID, + timestamp: yield* DateTime.now, + outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", + error: + failure !== undefined + ? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) } + : undefined, + }, + ) + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.asVoid, + ), + ), + ), + ) + return Service.of({ run, }) diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 09f227c259..51bafef779 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -43,32 +43,8 @@ export const AttachCommand = cmd({ alias: ["u"], type: "string", describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", - }) - .option("mini", { - type: "boolean", - describe: "start the minimal interactive interface", - default: false, - }) - .option("replay", { - type: "boolean", - hidden: true, - }) - .option("no-replay", { - type: "boolean", - describe: "disable mini session history replay on resume and after resize", - }) - .option("replay-limit", { - type: "number", - describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { - if (args.replay === true) { - UI.error("--replay is not supported; replay is enabled by default") - process.exitCode = 1 - return - } - const noReplay = args.replay === false || args.noReplay === true - const directory = (() => { if (!args.dir) return undefined try { @@ -80,32 +56,6 @@ export const AttachCommand = cmd({ } })() - if (args.mini) { - const { runMini } = await import("./run") - await runMini({ - attach: args.url, - directory, - password: args.password, - username: args.username, - continue: args.continue, - session: args.session, - fork: args.fork, - replay: noReplay ? false : undefined, - replayLimit: args.replayLimit, - }) - return - } - - const unsupported = [ - ["--no-replay", noReplay], - ["--replay-limit", args.replayLimit !== undefined], - ].find((entry) => entry[1])?.[0] - if (unsupported) { - UI.error(`${unsupported} requires --mini`) - process.exitCode = 1 - return - } - const { TuiConfig } = await import("@/config/tui") if (args.fork && !args.continue && !args.session) { UI.error("--fork requires --continue or --session") diff --git a/packages/opencode/src/cli/cmd/mini.ts b/packages/opencode/src/cli/cmd/mini.ts new file mode 100644 index 0000000000..96faa2e88e --- /dev/null +++ b/packages/opencode/src/cli/cmd/mini.ts @@ -0,0 +1,172 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { UI } from "@/cli/ui" +import { resolveThreadDirectory } from "./tui" + +type ReplayArgs = { + replay?: boolean + noReplay?: boolean +} + +type MiniArgs = ReplayArgs & { + continue?: boolean + session?: string + fork?: boolean + replayLimit?: number +} + +type MiniLocalArgs = MiniArgs & { + project?: string + model?: string + agent?: string + prompt?: string + demo?: boolean +} + +type MiniAttachArgs = MiniArgs & { + url: string + dir?: string + password?: string + username?: string +} + +function replay(args: ReplayArgs) { + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") + process.exitCode = 1 + return "invalid" as const + } + return args.replay === false || args.noReplay === true ? false : undefined +} + +function miniOptions(yargs: Argv) { + return yargs + .option("continue", { + alias: ["c"], + describe: "continue the last session", + type: "boolean", + }) + .option("session", { + alias: ["s"], + describe: "session id to continue", + type: "string", + }) + .option("fork", { + type: "boolean", + describe: "fork the session when continuing (use with --continue or --session)", + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible replay to the newest N messages", + }) +} + +/** @internal Exported for CLI parser tests. */ +export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({ + command: "$0 [project]", + describe: "start the minimal interactive interface", + builder: (yargs) => + miniOptions( + yargs + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("agent", { + type: "string", + describe: "agent to use", + }) + .option("prompt", { + type: "string", + describe: "prompt to use", + }) + .option("demo", { + type: "boolean", + hidden: true, + }), + ), + handler: async (args) => { + const shouldReplay = replay(args) + if (shouldReplay === "invalid") return + + const { runMini } = await import("./run") + await runMini({ + directory: resolveThreadDirectory(args.project), + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + prompt: args.prompt, + replay: shouldReplay, + replayLimit: args.replayLimit, + demo: args.demo, + }) + }, +}) + +/** @internal Exported for CLI parser tests. */ +export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({ + command: "attach ", + describe: "attach to a running opencode server with the minimal interface", + builder: (yargs) => + miniOptions( + yargs + .positional("url", { + type: "string", + describe: "http://localhost:4096", + demandOption: true, + }) + .option("dir", { + type: "string", + describe: "directory on the remote server", + }) + .option("password", { + alias: ["p"], + type: "string", + describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }), + ), + handler: async (args) => { + const shouldReplay = replay(args) + if (shouldReplay === "invalid") return + + const { runMini } = await import("./run") + await runMini({ + attach: args.url, + directory: args.dir, + password: args.password, + username: args.username, + continue: args.continue, + session: args.session, + fork: args.fork, + replay: shouldReplay, + replayLimit: args.replayLimit, + }) + }, +}) + +export const MiniCommand = cmd({ + command: "mini", + describe: "start the minimal interactive interface", + builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(), + handler: async () => {}, +}) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a0..235dce64be 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,13 +1,13 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { FSUtil } from "@opencode-ai/core/fs-util" -// CLI entry point for `opencode run` and `opencode --mini`. +// CLI entry point for `opencode run` and `opencode mini`. // // Handles three modes: // 1. Non-interactive (default): sends a single prompt, streams events to // stdout, and exits when the session goes idle. -// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode +// 2. Interactive local (`opencode mini`): boots the split-footer direct mode // with an in-process server (no external HTTP). -// 3. Interactive attach (`opencode --mini --attach`): connects to a running +// 3. Interactive attach (`opencode mini attach`): connects to a running // opencode server and runs interactive mode against it. // // Also supports `--command` for slash-command execution, `--format json` for @@ -25,6 +25,8 @@ import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" +import { isImageAttachment, isPdfAttachment } from "@/util/media" +import { loadRunAgents } from "./run/catalog.shared" type ModelInput = Parameters[0]["model"] @@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined { return value + "\n" + piped } +function isBinaryContent(bytes: Uint8Array) { + if (bytes.length === 0) return false + if (bytes.includes(0)) return true + return ( + bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 + ) +} + type FilePart = { type: "file" url: string @@ -68,6 +78,7 @@ type SessionInfo = { id: string title?: string directory?: string + current?: boolean } function inline(info: Inline) { @@ -158,10 +169,6 @@ export const RunCommand = effectCmd({ describe: "fork the session before continuing (requires --continue or --session)", type: "boolean", }) - .option("share", { - type: "boolean", - describe: "share the session", - }) .option("model", { type: "string", alias: ["m"], @@ -217,11 +224,6 @@ export const RunCommand = effectCmd({ type: "boolean", describe: "show thinking blocks", }) - .option("mini", { - type: "boolean", - hidden: true, - default: false, - }) .option("replay", { type: "boolean", default: true, @@ -270,7 +272,7 @@ export const RunCommand = effectCmd({ const localInstance = yield* InstanceRef yield* Effect.promise(async () => { const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const interactive = args.mini + const interactive = (args as typeof args & { mini?: boolean }).mini === true const auto = args.auto || args.yolo || args["dangerously-skip-permissions"] const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) const die = (message: string): never => { @@ -290,23 +292,23 @@ export const RunCommand = effectCmd({ .join(" ") if (interactive && args.command) { - die("--mini cannot be used with --command") + die("opencode mini cannot be used with --command") } if (interactive && args._?.[0] !== "mini") { - die("--mini must be used without the run subcommand") + die("opencode mini must be run with the mini command") } if (args.demo && !interactive) { - die("--demo requires --mini") + die("--demo requires opencode mini") } if (interactive && args.format === "json") { - die("--mini cannot be used with --format json") + die("opencode mini cannot be used with --format json") } if (args["replay-limit"] !== undefined && !interactive) { - die("--replay-limit requires --mini") + die("--replay-limit requires opencode mini") } if ( @@ -317,7 +319,7 @@ export const RunCommand = effectCmd({ } if (interactive && !process.stdout.isTTY) { - die("--mini requires a TTY stdout") + die("opencode mini requires a TTY stdout") } if (interactive) { @@ -355,6 +357,12 @@ export const RunCommand = effectCmd({ } const files: FilePart[] = [] + const fileInputs: Array<{ + filePath: string + resolvedPath: string + stat: ReturnType + isDirectory: boolean + }> = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] @@ -371,45 +379,7 @@ export const RunCommand = effectCmd({ UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) process.exit(1) } - - const content = await (async () => { - if (!args.attach) return - const handle = await open(resolvedPath, "r") - try { - const opened = await handle.stat() - if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { - UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`) - process.exit(1) - } - if (opened.size === 0) return Buffer.alloc(0) - const buffer = Buffer.alloc(Number(opened.size)) - let offset = 0 - while (offset < buffer.length) { - const read = await handle.read(buffer, offset, buffer.length - offset, offset) - if (read.bytesRead === 0) break - offset += read.bytesRead - } - return buffer.subarray(0, offset) - } finally { - await handle.close() - } - })() - const detected = FSUtil.mimeType(resolvedPath) - const text = content?.toString("utf8") - const mime = !args.attach - ? isDirectory - ? "application/x-directory" - : "text/plain" - : content && text !== undefined && Buffer.from(text, "utf8").equals(content) - ? "text/plain" - : detected - - files.push({ - type: "file", - url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) + fileInputs.push({ filePath, resolvedPath, stat, isDirectory }) } } @@ -446,6 +416,53 @@ export const RunCommand = effectCmd({ pattern: "*", }, ] + const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory) + + const inlineFiles = interactive || currentPrompt + for (const file of fileInputs) { + const content = await (async () => { + if (file.isDirectory || !inlineFiles) return + if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) + process.exit(1) + } + const handle = await open(file.resolvedPath, "r") + try { + const opened = await handle.stat() + if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) + process.exit(1) + } + if (opened.size === 0) return Buffer.alloc(0) + const buffer = Buffer.alloc(Number(opened.size)) + let offset = 0 + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset) + if (read.bytesRead === 0) break + offset += read.bytesRead + } + return buffer.subarray(0, offset) + } finally { + await handle.close() + } + })() + const detected = FSUtil.mimeType(file.resolvedPath) + const text = content?.toString("utf8") + const mime = file.isDirectory + ? "application/x-directory" + : isImageAttachment(detected) || isPdfAttachment(detected) + ? detected + : content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content) + ? "text/plain" + : detected + + files.push({ + type: "file", + url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href, + filename: path.basename(file.resolvedPath), + mime, + }) + } function title() { if (args.title === undefined) return @@ -453,58 +470,122 @@ export const RunCommand = effectCmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient): Promise { - if (args.session) { - const current = await sdk.session - .get({ - sessionID: args.session, - }) - .catch(() => undefined) + async function currentSession(sdk: OpencodeClient, sessionID: string): Promise { + const listed = await sdk.v2.session + .list({ + directory: await current(sdk), + limit: 50, + order: "desc", + }) + .then((result) => result.data?.data.find((item) => item.id === sessionID)) + .catch(() => undefined) + const selected = + listed ?? + (await sdk.v2.session + .get({ sessionID }) + .then((result) => result.data?.data) + .catch(() => undefined)) + const legacy = + selected ?? + (await sdk.session + .get({ sessionID }) + .then((result) => result.data) + .catch(() => undefined)) + const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID) + if (!legacy && transcript === "empty") { + return + } + if (interactive && transcript === "legacy") { + throw new Error("Mini cannot resume a legacy Session transcript") + } - if (!current?.data) { - UI.error("Session not found") - process.exit(1) - } - - if (args.fork) { - const forked = await sdk.session.fork({ - sessionID: args.session, - }) - const id = forked.data?.id - if (!id) { - return - } - - return { - id, - title: forked.data?.title ?? current.data.title, - directory: forked.data?.directory ?? current.data.directory, - } - } + return { + id: legacy?.id ?? sessionID, + title: legacy?.title, + directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk), + current: transcript !== "legacy", + } + } + async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise { + if (session.current !== false) { + const forked = await sdk.v2.session.fork( + { sessionID: session.id, messageID: undefined }, + { throwOnError: true }, + ) + await waitForFork(sdk, session.id, forked.data.data.id) return { - id: current.data.id, - title: current.data.title, - directory: current.data.directory, + id: forked.data.data.id, + title: forked.data.data.title, + directory: forked.data.data.location.directory, + current: true, } } - const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + const forked = await sdk.session.fork({ + sessionID: session.id, + }) + const id = forked.data?.id + if (!id) { + return + } - if (base && args.fork) { - const forked = await sdk.session.fork({ - sessionID: base.id, - }) - const id = forked.data?.id - if (!id) { + return { + id, + title: forked.data?.title ?? session.title, + directory: forked.data?.directory ?? session.directory, + current: false, + } + } + + async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) { + const parentHasMessages = await sdk.v2.session + .messages({ sessionID: parentID, limit: 1 }) + .then((result) => (result.data?.data.length ?? 0) > 0) + .catch(() => false) + if (!parentHasMessages) { + return + } + + const deadline = Date.now() + 3000 + while (Date.now() < deadline) { + const forkedHasMessages = await sdk.v2.session + .messages({ sessionID, limit: 1 }) + .then((result) => (result.data?.data.length ?? 0) > 0) + .catch(() => false) + if (forkedHasMessages) { return } - return { - id, - title: forked.data?.title ?? base.title, - directory: forked.data?.directory ?? base.directory, + await Bun.sleep(25) + } + } + + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await currentSession(sdk, args.session) + if (!current) { + UI.error("Session not found") + process.exit(1) } + if (!interactive && !currentPrompt && current.current !== false) { + throw new Error("This operation is not available for a current Session transcript") + } + + if (args.fork) { + return forkSession(sdk, current) + } + + return current + } + + const base = args.continue ? await currentRootSession(sdk) : undefined + if (base && !interactive && !currentPrompt && base.current !== false) { + throw new Error("This operation is not available for a current Session transcript") + } + + if (base && args.fork) { + return forkSession(sdk, base) } if (base) { @@ -512,6 +593,23 @@ export const RunCommand = effectCmd({ id: base.id, title: base.title, directory: base.directory, + current: "current" in base ? base.current : false, + } + } + + if (interactive || currentPrompt) { + const name = title() + const result = await sdk.v2.session.create({ + location: { directory: await current(sdk) }, + }) + const created = result.data?.data + if (!created) return + if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name }) + return { + id: created.id, + title: name ?? created.title, + directory: created.location.directory, + current: true, } } @@ -529,30 +627,45 @@ export const RunCommand = effectCmd({ id, title: result.data?.title ?? name, directory: result.data?.directory, + current: false, } } - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } + async function currentRootSession(sdk: OpencodeClient): Promise { + const response = await sdk.v2.session.list({ + directory: await current(sdk), + limit: 50, + order: "desc", }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + const root = (response.data?.data ?? []) + .filter((session) => !session.parentID) + .toSorted((a, b) => b.time.updated - a.time.updated)[0] + if (!root) return + return currentSession(sdk, root.id) + } + + async function transcriptKind(sdk: OpencodeClient, sessionID: string) { + const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0) + // Ordinary prompt flows assume a transcript with current messages is + // current-owned; only legacy-only modes (--command, directory + // attachments) still probe legacy history for mixed transcripts. + if (current && (interactive || currentPrompt)) return "current" as const + + const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0) + if (current) { + if (legacy) throw new Error("Session contains mixed legacy and current transcripts") + return "current" as const } + if (legacy) return "legacy" as const + return "empty" as const } async function createFreshSession( sdk: OpencodeClient, input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, ): Promise { - const result = await sdk.session.create({ - title: args.title !== undefined && args.title !== "" ? args.title : undefined, + const name = args.title !== undefined && args.title !== "" ? args.title : undefined + const result = await sdk.v2.session.create({ agent: input.agent, model: input.model ? { @@ -561,17 +674,18 @@ export const RunCommand = effectCmd({ variant: input.variant, } : undefined, - permission: [...rules], + location: { directory: await current(sdk) }, }) - const id = result.data?.id + const created = result.data?.data + const id = created?.id if (!id) { throw new Error("Failed to create session") } + if (name) await sdk.v2.session.rename({ sessionID: id, title: name }) - void share(sdk, id).catch(() => {}) return { id, - title: result.data?.title, + title: name ?? created.title, } } @@ -580,8 +694,8 @@ export const RunCommand = effectCmd({ return directory ?? root } - const next = await sdk.path - .get() + const next = await sdk.v2.location + .get(undefined, { throwOnError: true }) .then((x) => x.data?.directory) .catch(() => undefined) if (next) { @@ -622,10 +736,7 @@ export const RunCommand = effectCmd({ if (!args.agent) return undefined const name = args.agent - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined) if (!modes) { UI.println( @@ -636,7 +747,7 @@ export const RunCommand = effectCmd({ return undefined } - const agent = modes.find((a) => a.name === name) + const agent = modes.find((item) => item.name === name) if (!agent) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", @@ -823,9 +934,33 @@ export const RunCommand = effectCmd({ // Validate agent if specified const agent = await pickAgent(client) - await share(client, sessionID) - if (!interactive) { + if (currentPrompt && sess.current !== false) { + const model = pick(args.model) + const { runNonInteractivePrompt } = await import("./run/noninteractive") + try { + await runNonInteractivePrompt({ + client, + sessionID, + message, + files, + agent, + model, + variant: args.variant, + thinking, + format: args.format === "json" ? "json" : "default", + dangerouslySkipPermissions: args["dangerously-skip-permissions"], + renderTool: tool, + renderToolError: toolError, + }) + } catch (error) { + const output = error instanceof Error ? { type: "unknown", message: error.message } : error + if (!emit("error", { error: output })) UI.error(formatRunError(error)) + process.exitCode = 1 + } + return + } + const events = await client.event.subscribe() const completed = loop(client, events).catch((e) => { console.error(e) @@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({ directory: cwd, sessionID, sessionTitle: sess.title, - resume: Boolean(args.session || args.continue) && !args.fork, + resume: Boolean(args.session || args.continue), replay, replayLimit: args["replay-limit"], agent, @@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({ fetch: fetchFn, resolveAgent: localAgent, session, - share, createSession: createFreshSession, agent: args.agent, model, @@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) { continue: input.continue, session: input.session, fork: input.fork, - share: undefined, model: input.model, agent: input.agent, format: "default", @@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) { "dangerously-skip-permissions": false, dangerouslySkipPermissions: false, demo: input.demo ?? false, - }) + } as Parameters>[0] & { mini: boolean }) } diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/opencode/src/cli/cmd/run/catalog.shared.ts new file mode 100644 index 0000000000..de3e719821 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/catalog.shared.ts @@ -0,0 +1,114 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" + +type CurrentAgent = NonNullable>["data"]>["data"][number] +type CurrentCommand = NonNullable>["data"]>["data"][number] +type CurrentSkill = NonNullable>["data"]>["data"][number] +type CurrentProvider = NonNullable>["data"]>["data"][number] +type CurrentModel = NonNullable>["data"]>["data"][number] + +function location(directory: string) { + return { + location: { + directory, + }, + } +} + +function defaultCost(model: CurrentModel) { + const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0] + if (!picked) { + return undefined + } + + return { + ...picked, + input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input, + } +} + +export function runAgent(input: CurrentAgent): RunAgent { + return { + name: input.id, + description: input.description, + mode: input.mode, + hidden: input.hidden, + } +} + +export function runCommand(input: CurrentCommand): RunCommand { + return { + name: input.name, + description: input.description, + } +} + +export function runSkill(input: CurrentSkill): RunCommand { + return { + name: input.name, + description: input.description, + source: "skill", + } +} + +export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] { + const grouped = new Map() + + for (const provider of providers) { + grouped.set(provider.id, { + id: provider.id, + name: provider.name, + models: {}, + }) + } + + for (const model of models) { + const provider = grouped.get(model.providerID) ?? { + id: model.providerID, + name: model.providerID, + models: {}, + } + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + name: model.name, + capabilities: model.capabilities, + cost: defaultCost(model), + limit: model.limit, + status: model.status, + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])), + } + grouped.set(provider.id, provider) + } + + return [...grouped.values()] +} + +export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise { + const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) + return (result.data?.data ?? []).map(runAgent) +} + +export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise { + const [commands, skills] = await Promise.all([ + sdk.v2.command.list(location(directory), { throwOnError: true }), + sdk.v2.skill.list(location(directory), { throwOnError: true }), + ]) + return [ + ...(commands.data?.data ?? []).map(runCommand), + ...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill), + ] +} + +export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise { + const result = await sdk.v2.reference.list(location(directory), { throwOnError: true }) + return (result.data?.data ?? []).filter((reference) => !reference.hidden) +} + +export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise { + const [providers, models] = await Promise.all([ + sdk.v2.provider.list(location(directory), { throwOnError: true }), + sdk.v2.model.list(location(directory), { throwOnError: true }), + ]) + return runProviders(providers.data?.data ?? [], models.data?.data ?? []) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index 70cc2064fc..f513b4ab62 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -141,7 +141,9 @@ export function RunPermissionBody(props: { const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) - const opts = createMemo(() => permissionOptions(state().stage)) + const opts = createMemo(() => + permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0), + ) const busy = createMemo(() => state().submitting) const title = createMemo(() => { if (state().stage === "always") { @@ -165,7 +167,7 @@ export function RunPermissionBody(props: { }) const shift = (dir: -1 | 1) => { - setState((prev) => permissionShift(prev, dir)) + setState((prev) => permissionShift(prev, dir, opts())) } const submit = async (next: PermissionReply) => { diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 0280982d50..90efdc5695 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -1,7 +1,7 @@ // Prompt composer and its state machine for direct interactive mode. // // createPromptState() wires keymap command layers, history navigation, and -// `@` autocomplete for files, subagents, and MCP resources. +// `@` autocomplete for files, subagents, and project references. // It produces a PromptState that RunPromptBody renders as a slim single-line // composer while the footer view renders any active menus below it. /** @jsxImportSource @opentui/solid */ @@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap" import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types" +import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types" const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_BOTTOM_ROWS = 1 @@ -59,7 +59,7 @@ type PromptInput = { directory: string findFiles: (query: string) => Promise agents: Accessor - resources: Accessor + references: Accessor commands: Accessor tuiConfig: RunTuiConfig state: Accessor @@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState { }, })) }) - const resources = createMemo(() => { - return input.resources().map((item) => ({ + const references = createMemo(() => { + return input.references().map((item) => ({ kind: "mention", - display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()), + display: Locale.truncateMiddle("@" + item.name, width()), value: item.name, - description: item.description, + description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path), part: { type: "file", - mime: item.mimeType ?? "text/plain", + mime: "application/x-directory", filename: item.name, - url: item.uri, + url: pathToFileURL(item.path).href, source: { - type: "resource", - clientName: item.client, - uri: item.uri, + type: "file", + path: item.name, text: { start: 0, end: 0, @@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState { }, { initialValue: [] as Auto[] }, ) - const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()]) + const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()]) const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill")) const hasSkillsCommand = createMemo(() => (input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"), @@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState { return [ ...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj), ...files(), - ...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj), + ...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj), ] } diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index eb43b4b9f5..20cc6d8c7d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -53,6 +53,9 @@ export function RunFooterSubagentBody(props: { diffStyle?: RunDiffStyle onCycle: (dir: -1 | 1) => void onClose: () => void + // Formatted interrupt shortcut from the registered keymap binding; the + // command itself is dispatched through the keymap in footer.view. + interrupt?: () => string | undefined }) { const theme = createMemo(() => props.theme()) const footer = createMemo(() => theme().footer) @@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: { )) let scroll: ScrollBoxRenderable | undefined + const interruptHint = createMemo(() => { + if (tab()?.status !== "running") return undefined + return props.interrupt?.() + }) + useKeyboard((event) => { if (!props.active()) { return @@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: { {" " + subtitle()} + + {(hint) => ( + + {hint()} interrupt + + )} + 1 && props.index() > 0}> {props.index()} of {props.total()} diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 0d9da6f297..6a4c03e797 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -55,7 +55,7 @@ import type { RunInput, RunPrompt, RunProvider, - RunResource, + RunReference, RunTuiConfig, StreamCommit, } from "./types" @@ -71,7 +71,7 @@ type RunFooterOptions = { directory: string findFiles: (query: string) => Promise agents: RunAgent[] - resources: RunResource[] + references: RunReference[] commands?: RunCommand[] wrote?: boolean sessionID: () => string | undefined @@ -97,6 +97,7 @@ type RunFooterOptions = { onEditorOpen: (input: { value: string }) => Promise onExit?: () => void onSubagentSelect?: (sessionID: string | undefined) => void + onSubagentInterrupt?: (sessionID: string) => void treeSitterClient?: TreeSitterClient } @@ -180,8 +181,8 @@ export class RunFooter implements FooterApi { private rows = TEXTAREA_MIN_ROWS private agents: Accessor private setAgents: Setter - private resources: Accessor - private setResources: Setter + private references: Accessor + private setReferences: Setter private commands: Accessor private setCommands: Setter private providers: Accessor @@ -255,9 +256,9 @@ export class RunFooter implements FooterApi { const [agents, setAgents] = createSignal(options.agents) this.agents = agents this.setAgents = setAgents - const [resources, setResources] = createSignal(options.resources) - this.resources = resources - this.setResources = setResources + const [references, setReferences] = createSignal(options.references) + this.references = references + this.setReferences = setReferences const [commands, setCommands] = createSignal(options.commands) this.commands = commands this.setCommands = setCommands @@ -311,7 +312,7 @@ export class RunFooter implements FooterApi { queuedPrompts: footer.queuedPrompts, findFiles: options.findFiles, agents: footer.agents, - resources: footer.resources, + references: footer.references, commands: footer.commands, providers: footer.providers, currentModel: footer.currentModel, @@ -341,6 +342,7 @@ export class RunFooter implements FooterApi { onLayout: footer.syncLayout, onStatus: footer.setStatus, onSubagentSelect: options.onSubagentSelect, + onSubagentInterrupt: options.onSubagentInterrupt, onQueuedRemove: footer.handleQueuedRemove, }) }, @@ -411,7 +413,7 @@ export class RunFooter implements FooterApi { } this.setAgents(next.agents) - this.setResources(next.resources) + this.setReferences(next.references) if (next.commands !== undefined) { this.setCommands(next.commands) } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index ecddc50e49..245a24816d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -50,7 +50,7 @@ import type { RunInput, RunPrompt, RunProvider, - RunResource, + RunReference, RunTuiConfig, } from "./types" import type { RunTheme } from "./theme" @@ -74,7 +74,7 @@ type RunFooterViewProps = { directory: string findFiles: (query: string) => Promise agents: () => RunAgent[] - resources: () => RunResource[] + references: () => RunReference[] commands: () => RunCommand[] | undefined providers: () => RunProvider[] | undefined currentModel: () => RunInput["model"] @@ -108,6 +108,7 @@ type RunFooterViewProps = { onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void onStatus: (text: string) => void onSubagentSelect?: (sessionID: string | undefined) => void + onSubagentInterrupt?: (sessionID: string) => void onQueuedRemove: (messageID: string) => Promise } @@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) { props.tuiConfig, ) ?? "", ) + const subagentInterruptShortcut = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeySequence( + keymap + .getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] }) + .get("subagent.interrupt")?.[0]?.sequence, + props.tuiConfig, + ) ?? "", + ) const interrupt = useKeymapSelector( (keymap: OpenTuiKeymap) => formatKeySequence( @@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) { directory: props.directory, findFiles: props.findFiles, agents: props.agents, - resources: props.resources, + references: props.references, commands: props.commands, tuiConfig: props.tuiConfig, state: props.state, @@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) { useBindings(() => ({ mode: OPENCODE_BASE_MODE, - enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(), + enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground, priority: 1, commands: [ { @@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) { bindings: props.tuiConfig.keybinds.get("session.queued_prompts"), })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: + active().type === "prompt" && + route().type === "subagent" && + selectedTab()?.status === "running" && + !!props.onSubagentInterrupt, + priority: 1, + commands: [ + { + name: "subagent.interrupt", + title: "Interrupt subagent", + category: "Session", + run: () => { + const current = selectedTab() + if (current?.status !== "running") { + return + } + + props.onSubagentInterrupt?.(current.sessionID) + }, + }, + ], + bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }], + })) + createEffect(() => { const current = route() if (current.type !== "subagent") { @@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) { diffStyle={props.diffStyle} onCycle={cycleTab} onClose={closeTab} + interrupt={() => subagentInterruptShortcut() || undefined} /> diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/opencode/src/cli/cmd/run/noninteractive.ts new file mode 100644 index 0000000000..440436c704 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/noninteractive.ts @@ -0,0 +1,459 @@ +import type { + OpencodeClient, + ReasoningPart, + StepFinishPart, + StepStartPart, + TextPart, + ToolPart, + V2Event, +} from "@opencode-ai/sdk/v2" +import { EOL } from "node:os" +import { MessageID } from "@/session/schema" +import { UI } from "../../ui" + +type Model = { + providerID: string + modelID: string +} + +type File = { + url: string + filename: string + mime: string +} + +type Input = { + client: OpencodeClient + sessionID: string + message: string + files: File[] + agent?: string + model?: Model + variant?: string + thinking: boolean + format: "default" | "json" + dangerouslySkipPermissions: boolean + renderTool: (part: ToolPart) => Promise + renderToolError: (part: ToolPart) => Promise +} + +type StartedPart = { + id: string + timestamp: number +} + +type ToolState = StartedPart & { + assistantMessageID: string + tool: string + input: Record + raw?: string + provider?: unknown +} + +export async function runNonInteractivePrompt(input: Input) { + const controller = new AbortController() + const events = await input.client.v2.event.subscribe({ + signal: controller.signal, + sseMaxRetryAttempts: 0, + throwOnError: true, + }) + const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator + const connected = await stream.next() + if (connected.done) throw new Error("Event stream disconnected before prompt admission") + + const messageID = MessageID.ascending() + const starts = new Map() + const tools = new Map() + let submitted = false + let promoted = false + let emittedError = false + let questionRejected = false + let permissionRejected = false + let interrupted = false + let admission: AbortController | undefined + + const emit = (type: string, timestamp: number, data: Record) => { + if (input.format !== "json") return false + process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL) + return true + } + + const writeText = (part: TextPart, timestamp: number) => { + if (emit("text", timestamp, { part })) return + const text = part.text.trim() + if (!text) return + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + return + } + UI.empty() + UI.println(text) + UI.empty() + } + + const replyPermission = async (request: { id: string; action: string; resources: string[] }) => { + if (!input.dangerouslySkipPermissions) { + permissionRejected = true + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`, + ) + } + await input.client.v2.session.permission + .reply({ + sessionID: input.sessionID, + requestID: request.id, + reply: input.dangerouslySkipPermissions ? "once" : "reject", + }) + .catch(() => {}) + if (!input.dangerouslySkipPermissions) { + await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + } + + const rejectQuestion = async (request: { id: string }) => { + questionRejected = true + await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) + } + + const consume = async () => { + while (!controller.signal.aborted) { + const next = await stream.next() + if (next.done) throw new Error("Event stream disconnected during prompt execution") + const event = next.value + + if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) { + await replyPermission(event.data) + continue + } + if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) { + await rejectQuestion(event.data) + continue + } + if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue + const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now() + + if (event.type === "session.next.prompted") { + if (event.data.messageID === messageID) { + promoted = true + continue + } + if (promoted && event.data.delivery === "queue") return + } + if ( + event.type === "session.next.execution.settled" && + event.data.outcome === "interrupted" && + (interrupted || permissionRejected || questionRejected) + ) { + return + } + if (!promoted) continue + + if (event.type === "session.next.step.started") { + const part: StepStartPart = { + id: partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "step-start", + snapshot: event.data.snapshot, + } + if (!emit("step_start", time, { part }) && input.format !== "json") { + UI.empty() + UI.println(`> ${event.data.agent} · ${event.data.model.id}`) + UI.empty() + } + continue + } + + if (event.type === "session.next.text.started") { + starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) + continue + } + if (event.type === "session.next.text.ended") { + const started = starts.get(event.data.textID) + const part: TextPart = { + id: started?.id ?? partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "text", + text: event.data.text, + time: { start: started?.timestamp ?? time, end: time }, + } + writeText(part, time) + continue + } + + if (event.type === "session.next.reasoning.started") { + starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) + continue + } + if (event.type === "session.next.reasoning.ended" && input.thinking) { + const started = starts.get(event.data.reasoningID) + const part: ReasoningPart = { + id: started?.id ?? partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "reasoning", + text: event.data.text, + metadata: event.data.providerMetadata, + time: { start: started?.timestamp ?? time, end: time }, + } + if (emit("reasoning", time, { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (!process.stdout.isTTY) { + process.stdout.write(line + EOL) + continue + } + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + + if (event.type === "session.next.tool.input.started") { + tools.set(event.data.callID, { + id: partID(event.id), + timestamp: time, + assistantMessageID: event.data.assistantMessageID, + tool: event.data.name, + input: {}, + }) + continue + } + if (event.type === "session.next.tool.input.ended") { + const current = tools.get(event.data.callID) + if (current) current.raw = event.data.text + continue + } + if (event.type === "session.next.tool.called") { + const current = tools.get(event.data.callID) + tools.set(event.data.callID, { + id: current?.id ?? partID(event.id), + timestamp: current?.timestamp ?? time, + assistantMessageID: event.data.assistantMessageID, + tool: event.data.tool, + input: event.data.input, + raw: current?.raw, + provider: event.data.provider, + }) + continue + } + if (event.type === "session.next.tool.success") { + const current = tools.get(event.data.callID) ?? fallbackTool(event) + const part: ToolPart = { + id: current.id, + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "tool", + callID: event.data.callID, + tool: current.tool, + state: { + status: "completed", + input: current.input, + output: event.data.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"), + title: current.tool, + metadata: { + structured: event.data.structured, + content: event.data.content, + outputPaths: event.data.outputPaths, + result: event.data.result, + providerCall: current.provider, + providerResult: event.data.provider, + rawInput: current.raw, + }, + time: { start: current.timestamp, end: time }, + }, + } + tools.delete(event.data.callID) + if (!emit("tool_use", time, { part })) await input.renderTool(part) + continue + } + if (event.type === "session.next.tool.failed") { + const current = tools.get(event.data.callID) ?? fallbackTool(event) + const error = event.data.error.message + const part: ToolPart = { + id: current.id, + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "tool", + callID: event.data.callID, + tool: current.tool, + state: { + status: "error", + input: current.input, + error, + metadata: { + result: event.data.result, + providerCall: current.provider, + providerResult: event.data.provider, + rawInput: current.raw, + }, + time: { start: current.timestamp, end: time }, + }, + } + tools.delete(event.data.callID) + if (!emit("tool_use", time, { part })) { + await input.renderToolError(part) + UI.error(error) + } + continue + } + + if (event.type === "session.next.step.ended") { + const part: StepFinishPart = { + id: partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "step-finish", + reason: event.data.finish, + snapshot: event.data.snapshot, + cost: event.data.cost, + tokens: event.data.tokens, + } + emit("step_finish", time, { part }) + continue + } + if (event.type === "session.next.step.failed") { + if (interrupted || permissionRejected || questionRejected) continue + emittedError = true + process.exitCode = 1 + if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) + continue + } + if (event.type === "session.next.execution.settled") { + if (event.data.outcome === "failure" && !emittedError && !questionRejected) { + emittedError = true + process.exitCode = 1 + const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } + if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message) + } + if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 + return + } + } + } + + const interrupt = () => { + if (interrupted) process.exit(130) + interrupted = true + process.exitCode = 130 + admission?.abort() + void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + process.on("SIGINT", interrupt) + + let completed: Promise | undefined + try { + if (input.agent) { + await input.client.v2.session.switchAgent( + { sessionID: input.sessionID, agent: input.agent }, + { throwOnError: true }, + ) + } + const selected = input.model + ? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant } + : input.variant + ? await input.client.v2.session + .get({ sessionID: input.sessionID }, { throwOnError: true }) + .then((result) => result.data.data.model) + .then(async (model) => { + if (model) return { ...model, variant: input.variant } + const result = await input.client.v2.model.default(undefined, { throwOnError: true }) + const fallback = result.data.data + return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined + }) + : undefined + if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model") + if (selected) { + await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true }) + } + + const prepared = await Promise.all(input.files.map(prepareFile)) + if (interrupted) return + submitted = true + completed = consume() + admission = new AbortController() + const response = await input.client.v2.session + .prompt( + { + sessionID: input.sessionID, + id: messageID, + prompt: { + text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), + }, + delivery: "steer", + }, + { throwOnError: true, signal: admission.signal }, + ) + .catch(async (error) => { + if (interrupted) { + await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + controller.abort() + await completed?.catch(() => {}) + if (interrupted) return undefined + throw error + }) + admission = undefined + if (!response) return + if (!response.data.data) throw new Error("Prompt was not admitted") + if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + + const [permissions, questions] = await Promise.all([ + input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined), + input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined), + ]) + await Promise.all([ + ...(permissions?.data?.data ?? []).map(replyPermission), + ...(questions?.data?.data ?? []).map(rejectQuestion), + ]) + await completed + } finally { + process.off("SIGINT", interrupt) + controller.abort() + await stream.return?.(undefined).catch(() => {}) + } +} + +function partID(eventID: string) { + return `prt_${eventID.replace(/^evt_/, "")}` +} + +function fallbackTool(event: { + id: string + data: { timestamp: number; assistantMessageID: string; callID: string } +}): ToolState { + return { + id: partID(event.id), + timestamp: toMillis(event.data.timestamp), + assistantMessageID: event.data.assistantMessageID, + tool: "tool", + input: {}, + } +} + +function toMillis(value: unknown) { + if (typeof value === "number") return value + if (typeof value === "string") return new Date(value).getTime() + return Date.now() +} + +async function prepareFile(file: File) { + if (file.mime !== "text/plain") { + const uri = file.url.startsWith("data:") + ? file.url + : `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}` + return { attachment: { uri, mime: file.mime, name: file.filename } } + } + const content = file.url.startsWith("data:") + ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8") + : await Bun.file(new URL(file.url)).text() + return { text: `\n${content}\n` } +} diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/opencode/src/cli/cmd/run/permission.shared.ts index 6ebdbd090c..09cbf36df2 100644 --- a/packages/opencode/src/cli/cmd/run/permission.shared.ts +++ b/packages/opencode/src/cli/cmd/run/permission.shared.ts @@ -150,8 +150,11 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply } } -export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState { - const list = permissionOptions(state.stage) +export function permissionShift( + state: PermissionBodyState, + dir: -1 | 1, + list = permissionOptions(state.stage), +): PermissionBodyState { if (list.length === 0) { return state } diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index b1f6217846..4753adaae2 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -8,9 +8,12 @@ import { Context, Effect, Layer } from "effect" import { resolve } from "@opencode-ai/tui/config" import { TuiConfig } from "@/config/tui" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeRuntime } from "@/effect/run-service" +import { loadRunProviders } from "./catalog.shared" import { reusePendingTask } from "./runtime.shared" -import { resolveSession, sessionHistory } from "./session.shared" +import { resolveCurrentSession, sessionHistory } from "./session.shared" import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import { pickVariant } from "./variant.shared" @@ -95,20 +98,7 @@ const layer = Layer.effect( directory: string, model: RunInput["model"], ) { - const connected = yield* Effect.promise(() => - sdk.config - .providers({ directory }) - .then((item) => item.data?.providers) - .catch(() => undefined), - ) - const providers = yield* Effect.promise(() => - connected - ? Promise.resolve(connected) - : sdk.provider - .list() - .then((item) => item.data?.all ?? []) - .catch(() => []), - ) + const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory)) const limits = Object.fromEntries( providers.flatMap((provider) => Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => { @@ -143,7 +133,7 @@ const layer = Layer.effect( sessionID: string, model: RunInput["model"], ) { - const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined)) + const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined)) if (!session) { return emptySessionInfo() } @@ -172,7 +162,8 @@ const layer = Layer.effect( }), ) -const runtime = makeRuntime(Service, layer) +const node = makeGlobalNode({ service: Service, layer, deps: [] }) +const runtime = makeRuntime(Service, AppNodeBuilder.build(node)) // Fetches available variants and context limits for every provider/model pair. export async function resolveModelInfo( diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index 4644d3d036..a25b293883 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -27,7 +27,7 @@ import type { RunAgent, RunInput, RunPrompt, - RunResource, + RunReference, RunTuiConfig, } from "./types" import { formatModelLabel } from "./variant.shared" @@ -55,7 +55,7 @@ export type LifecycleInput = { directory: string findFiles: (query: string) => Promise agents: RunAgent[] - resources: RunResource[] + references: RunReference[] sessionID: string sessionTitle?: string getSessionID?: () => string | undefined @@ -75,6 +75,7 @@ export type LifecycleInput = { onInterrupt?: () => void onBackground?: () => void onSubagentSelect?: (sessionID: string | undefined) => void + onSubagentInterrupt?: (sessionID: string) => void } export type Lifecycle = { @@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise input.sessionID), ...labels, model: input.model, @@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index d236fb02c2..fcb1a40a8e 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -1,7 +1,7 @@ import fs from "fs" import * as tty from "node:tty" -export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input" +export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input" type InteractiveStdin = { stdin: NodeJS.ReadStream diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 90cddffa22..8f3704fd41 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -1,4 +1,4 @@ -// Top-level orchestrator for `opencode --mini`. +// Top-level orchestrator for `opencode mini`. // // Wires the boot sequence, lifecycle (renderer + footer), stream transport, // and prompt queue together into a single session loop. Two entry points: @@ -15,6 +15,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" +import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -62,7 +63,6 @@ type RunLocalInput = { fetch: typeof globalThis.fetch resolveAgent: () => Promise session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined> - share: (sdk: RunInput["sdk"], sessionID: string) => Promise createSession?: CreateSession agent: RunInput["agent"] model: RunInput["model"] @@ -77,7 +77,7 @@ type RunLocalInput = { } type StreamTransportModule = Pick< - Awaited, + Awaited, "createSessionTransport" | "formatUnknownError" > @@ -164,11 +164,9 @@ async function resolveExitTitle( return undefined } - return ctx.sdk.session - .get({ - sessionID: state.sessionID, - }) - .then((x) => x.data?.title) + return ctx.sdk.v2.session + .get({ sessionID: state.sessionID }) + .then((x) => x.data?.data.title) .catch(() => undefined) } @@ -233,7 +231,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep .then((x) => x.data ?? []) .catch(() => []), agents: [], - resources: [], + references: [], sessionID: state.sessionID, sessionTitle: state.sessionTitle, getSessionID: () => state.sessionID, @@ -250,21 +248,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } log?.write("send.permission.reply", next) - await ctx.sdk.permission.reply(next) + await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next }) }, onQuestionReply: async (next) => { if (state.demo?.questionReply(next)) { return } - await ctx.sdk.question.reply(next) + await ctx.sdk.v2.session.question.reply({ + sessionID: state.sessionID, + requestID: next.requestID, + questionV2Reply: { answers: next.answers ?? [] }, + }) }, onQuestionReject: async (next) => { if (state.demo?.questionReject(next)) { return } - await ctx.sdk.question.reject(next) + await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next }) }, onCycleVariant: () => { if (!state.model || state.variants.length === 0) { @@ -339,22 +341,30 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }, onInterrupt: () => { if (!hasSession(input, state) || state.aborting) { - return + return false } state.aborting = true - void ctx.sdk.session - .abort({ - sessionID: state.sessionID, - }) + void (state.stream + ? state.stream.then((item) => item.handle.interruptActiveTurn()) + : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })) .catch(() => {}) .finally(() => { state.aborting = false }) + return true }, onBackground: () => { - if (!hasSession(input, state)) return - void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {}) + if (!hasSession(input, state)) { + return + } + + log?.write("send.background", { sessionID: state.sessionID }) + void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {}) + }, + onSubagentInterrupt: (sessionID) => { + log?.write("send.subagent.interrupt", { sessionID }) + void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {}) }, onSubagentSelect: (sessionID) => { state.selectSubagent?.(sessionID) @@ -373,19 +383,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return } - const [agents, resources, commands] = await Promise.all([ - ctx.sdk.app - .agents({ directory: ctx.directory }) - .then((x) => x.data ?? []) - .catch(() => []), - ctx.sdk.experimental.resource - .list({ directory: ctx.directory }) - .then((x) => Object.values(x.data ?? {})) - .catch(() => []), - ctx.sdk.command - .list({ directory: ctx.directory }) - .then((x) => x.data ?? []) - .catch(() => []), + const [agents, references, commands] = await Promise.all([ + loadRunAgents(ctx.sdk, ctx.directory).catch(() => []), + loadRunReferences(ctx.sdk, ctx.directory).catch(() => []), + loadRunCommands(ctx.sdk, ctx.directory).catch(() => []), ]) if (footer.isClosed) { return @@ -394,7 +395,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer.event({ type: "catalog", agents, - resources, + references, commands, }) } @@ -453,7 +454,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) }) - const streamTask = deps.streamTransport ?? import("./stream.transport") + const streamTask = deps.streamTransport ?? import("./stream-v2.transport") const ensureStream = () => { if (state.stream) { return state.stream @@ -758,7 +759,6 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise {}) return { sessionID: next.id, sessionTitle: next.title, diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 03951ec4c9..05daa8b423 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -5,9 +5,9 @@ // - FooterOutput: status bar patches and view transitions (permission, question) // // The reducer mutates SessionData in place for performance but has no -// external side effects -- no IO, no footer calls. The caller -// (stream.transport.ts) feeds events in and forwards output to the footer -// through stream.ts. +// external side effects -- no IO, no footer calls. The demo runtime +// (demo.ts) feeds events in and forwards output to the footer through +// stream.ts; the current transport reuses the blocker helpers below. // // Key design decisions: // @@ -24,7 +24,7 @@ // `data.questions`. The footer shows whichever is first. When a reply // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. -import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import * as Locale from "@/util/locale" import { toolView } from "./tool" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" @@ -280,33 +280,6 @@ function remove(list: Array<{ id: string }>, id: string): boolean { return true } -export function bootstrapSessionData(input: { - data: SessionData - messages: Array<{ - parts: Part[] - }> - permissions: PermissionRequest[] - questions: QuestionRequest[] -}) { - for (const message of input.messages) { - for (const part of message.parts) { - if (part.type !== "tool") { - continue - } - - input.data.call.set(key(part.messageID, part.callID), part.state.input) - } - } - - for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) { - upsert(input.data.permissions, enrichPermission(input.data, request)) - } - - for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) { - upsert(input.data.questions, request) - } -} - function key(msg: string, call: string): string { return `${msg}:${call}` } @@ -740,26 +713,6 @@ function failTool(part: ToolPart, text: string): SessionCommit { }) } -// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted. -export function flushInterrupted(data: SessionData, commits: SessionCommit[]) { - for (const partID of data.part.keys()) { - if (data.ids.has(partID)) { - continue - } - - const msg = data.msg.get(partID) - if (msg && data.role.get(msg) === "user" && !data.includeUserText) { - data.ids.add(partID) - drop(data, partID) - continue - } - - flushPart(data, commits, partID, true) - data.ids.add(partID) - drop(data, partID) - } -} - // The main reducer. Takes one SDK event and returns scrollback commits and // footer updates. Called once per event from the stream transport's watch loop. // diff --git a/packages/opencode/src/cli/cmd/run/session-replay.ts b/packages/opencode/src/cli/cmd/run/session-replay.ts deleted file mode 100644 index 69a24f2719..0000000000 --- a/packages/opencode/src/cli/cmd/run/session-replay.ts +++ /dev/null @@ -1,374 +0,0 @@ -import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2" -import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data" -import { messagePrompt, type SessionMessages } from "./session.shared" -import { messageTurnSummaryCommit } from "./turn-summary" -import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types" - -type ReplayInput = { - messages: SessionMessages - permissions: PermissionRequest[] - questions: QuestionRequest[] - thinking: boolean - limits: Record - providers?: RunProvider[] -} - -type ReplayConfig = { - limits: Record - providers?: RunProvider[] - summaries: ReadonlySet -} - -export type SessionReplay = { - data: SessionData - commits: StreamCommit[] - patch?: FooterPatch -} - -type ReplayMessage = { - commits: StreamCommit[] - patch?: FooterPatch -} - -const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user" - -function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record) { - return reduceSessionData({ - data, - event, - sessionID, - thinking, - limits, - }) -} - -function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) { - if (!left) { - return right - } - - if (!right) { - return left - } - - return { - ...left, - ...right, - } -} - -function active(data: SessionData) { - return data.part.size > 0 || data.tools.size > 0 -} - -function replayPatch(data: SessionData, patch: FooterPatch | undefined) { - if (active(data)) { - if (!patch) { - return { - phase: "running", - } satisfies FooterPatch - } - - return { - ...patch, - phase: "running", - } satisfies FooterPatch - } - - if (data.permissions.length > 0 || data.questions.length > 0) { - if (!patch) { - return { - phase: "idle", - } satisfies FooterPatch - } - - return { - ...patch, - phase: "idle", - } satisfies FooterPatch - } - - if (!patch) { - return undefined - } - - return { - ...patch, - phase: "idle", - status: "", - } satisfies FooterPatch -} - -function isShellSyntheticUser(message: SessionMessages[number]) { - if (message.info.role !== "user") { - return false - } - - const prompt = messagePrompt(message) - return ( - !prompt.text.trim() && - prompt.parts.length === 0 && - message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT) - ) -} - -function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet) { - return ( - message.info.role === "assistant" && - shellParents.has(message.info.parentID) && - message.parts.some((part) => part.type === "tool" && part.tool === "bash") - ) -} - -function summaryMessageIDs(messages: SessionMessages): ReadonlySet { - const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id)) - const parents = new Set() - const summaries = new Set() - - for (let idx = messages.length - 1; idx >= 0; idx -= 1) { - const message = messages[idx] - if (!message || message.info.role !== "assistant") { - continue - } - - if (isShellSyntheticAssistant(message, shellParents)) { - continue - } - - if (parents.has(message.info.parentID)) { - continue - } - - parents.add(message.info.parentID) - - const completed = message.info.time.completed - if (typeof completed === "number" && completed > message.info.time.created) { - summaries.add(message.info.id) - } - } - - return summaries -} - -function replayMessage( - data: SessionData, - message: SessionMessages[number], - thinking: boolean, - config: ReplayConfig, -): ReplayMessage { - if (message.info.role === "user") { - const prompt = messagePrompt(message) - if (!prompt.text.trim()) { - return { - commits: [], - } - } - - return { - commits: [ - { - kind: "user", - text: prompt.text, - phase: "start", - source: "system", - messageID: message.info.id, - }, - ], - } - } - - const commits: StreamCommit[] = [] - let patch: FooterPatch | undefined - - const info = apply( - data, - { - id: `bootstrap:message:${message.info.id}`, - type: "message.updated", - properties: { - sessionID: message.info.sessionID, - info: message.info, - }, - }, - message.info.sessionID, - thinking, - config.limits, - ) - commits.push(...info.commits) - patch = mergePatch(patch, info.footer?.patch) - - for (const part of message.parts) { - const next = apply( - data, - { - id: `bootstrap:part:${part.id}`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 0, - }, - }, - message.info.sessionID, - thinking, - config.limits, - ) - patch = mergePatch(patch, next.footer?.patch) - commits.push(...next.commits) - } - - const summary = config.summaries.has(message.info.id) - ? messageTurnSummaryCommit(message, config.providers) - : undefined - if (summary) { - commits.push(summary) - } - - return { - commits, - patch, - } -} - -export function replaySession(input: ReplayInput): SessionReplay { - const data = createSessionData() - const commits: StreamCommit[] = [] - let patch: FooterPatch | undefined - const summaries = summaryMessageIDs(input.messages) - - bootstrapSessionData({ - data, - messages: input.messages, - permissions: input.permissions, - questions: input.questions, - }) - - for (const message of input.messages) { - const next = replayMessage(data, message, input.thinking, { - limits: input.limits, - providers: input.providers, - summaries, - }) - commits.push(...next.commits) - patch = mergePatch(patch, next.patch) - } - - return { - data, - commits, - patch: replayPatch(data, patch), - } -} - -export function replayLocalRows( - messages: SessionMessages, - commits: StreamCommit[], - rows: LocalReplayRow[], -): StreamCommit[] { - const persisted = new Set(messages.map((message) => message.info.id)) - return rows.reduce((out, local) => { - const row = local.commit - if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) { - return out - } - - if (!row.messageID) { - return [...out, row] - } - - const exact = local.after - ? out.findIndex( - (commit) => - commit.kind === local.after?.kind && - commit.text === local.after.text && - commit.phase === local.after.phase && - commit.toolState === local.after.toolState && - (local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID), - ) - : -1 - const anchored = - exact !== -1 - ? exact - : local.after - ? out.findLastIndex((commit) => - local.after?.partID - ? commit.partID === local.after.partID - : commit.kind === local.after?.kind && commit.messageID === local.after.messageID, - ) - : -1 - if (anchored !== -1) { - const commit = out[anchored] - const visible = local.after?.visible - if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) { - return [ - ...out.slice(0, anchored), - { ...commit, text: visible }, - row, - { ...commit, text: commit.text.slice(visible.length) }, - ...out.slice(anchored + 1), - ] - } - - return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)] - } - - const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID) - if (after !== -1) { - return [...out.slice(0, after + 1), row, ...out.slice(after + 1)] - } - - const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID) - if (before === -1) { - return [...out, row] - } - - return [...out.slice(0, before), row, ...out.slice(before)] - }, commits) -} - -export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] { - return [...current.part.entries()].flatMap(([partID, kind]) => { - if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) { - return [] - } - - const text = current.text.get(partID) ?? "" - const existing = data.text.get(partID) ?? "" - const sent = current.sent.get(partID) ?? 0 - const existingSent = data.sent.get(partID) ?? 0 - const visible = current.visible.get(partID) ?? "" - const existingVisible = data.visible.get(partID) ?? "" - if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) { - return [] - } - - data.part.set(partID, kind) - data.text.set(partID, text) - data.sent.set(partID, sent) - data.visible.set(partID, visible) - const messageID = current.msg.get(partID) - if (messageID) { - data.msg.set(partID, messageID) - const role = current.role.get(messageID) - if (role) { - data.role.set(messageID, role) - } - } - - const chunk = visible.slice(existingVisible.length) - if (!chunk) { - return [] - } - - return [ - { - kind, - text: chunk, - phase: "progress", - source: kind, - ...(messageID ? { messageID } : {}), - partID, - }, - ] satisfies StreamCommit[] - }) -} diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/opencode/src/cli/cmd/run/session.shared.ts index 7dbce26efd..49dece5a89 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/opencode/src/cli/cmd/run/session.shared.ts @@ -152,12 +152,52 @@ export function createSession(messages: SessionMessages): RunSession { } } -export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise { - const response = await sdk.session.messages({ - sessionID, - limit, - }) - return createSession(response.data ?? []) +export async function resolveCurrentSession( + sdk: RunInput["sdk"], + sessionID: string, + limit = LIMIT, +): Promise { + const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }) + const messages = response.data.data.toReversed() + const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true }) + return { + first: messages.length === 0, + turns: messages.flatMap((message) => { + if (message.type !== "user") return [] + return [ + { + prompt: { + text: message.text, + parts: [ + ...(message.files ?? []).map((file) => ({ + type: "file" as const, + url: file.uri, + mime: file.mime, + filename: file.name, + source: file.source + ? { + type: "file" as const, + path: file.name ?? file.uri, + text: { start: file.source.start, end: file.source.end, value: file.source.text }, + } + : undefined, + })), + ...(message.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.source + ? { start: agent.source.start, end: agent.source.end, value: agent.source.text } + : undefined, + })), + ], + }, + provider: session.data.data.model?.providerID, + model: session.data.data.model?.id, + variant: session.data.data.model?.variant, + }, + ] + }), + } } export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] { diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 141ff6fc55..9e9321cafd 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback lines, body_left + label.length, top + 1, - `opencode --mini -s ${meta.session_id}`, + `opencode mini -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD, diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts new file mode 100644 index 0000000000..1988d18d89 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts @@ -0,0 +1,698 @@ +// Current-native subagent (child Session) tracking for the mini transport. +// +// Discovers child Sessions of the active parent from four current sources: +// 1. projected subagent tool output (`structured.sessionID`) during hydration +// 2. the current session list filtered by `parentID` during hydration +// 3. the process-local active-session map during hydration +// 4. live events from unknown sessions whose `parentID` matches the parent +// +// Tracks one footer tab per child and a detail transcript for the selected +// child, reduced from the same current live event stream the parent uses. +// Detail transcripts rebuild from projected messages on discovery, selection, +// and reconnect, then continue from live deltas using the same +// projected-prefix dedup the parent transport uses. +// +// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child +// backgrounding is intentionally absent: subagent jobs block the parent +// session, so only whole-session `v2.session.background(parentID)` exists. +import type { + OpencodeClient, + SessionMessage, + SessionMessageAssistantTool, + ToolPart, + V2Event, +} from "@opencode-ai/sdk/v2" +import { Locale } from "@/util/locale" +import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" + +const CHILD_MESSAGE_LIMIT = 80 +const CHILD_FRAME_LIMIT = 80 +const DISCOVERY_BUFFER_LIMIT = 64 +const FAMILY_LIST_LIMIT = 100 +const FALLBACK_LABEL = "Subagent" + +export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) { + return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") +} + +export function legacyTool(input: { + sessionID: string + messageID: string + callID: string + name: string + state: SessionMessageAssistantTool["state"] + time: SessionMessageAssistantTool["time"] + provider?: SessionMessageAssistantTool["provider"] +}): ToolPart { + const base = { + id: `prt_${input.callID}`, + sessionID: input.sessionID, + messageID: input.messageID, + type: "tool" as const, + callID: input.callID, + tool: input.name, + } + if (input.state.status === "pending") { + return { + ...base, + state: { status: "pending", input: {}, raw: input.state.input }, + } + } + if (input.state.status === "running") { + return { + ...base, + state: { + status: "running", + input: input.state.input, + title: input.name, + metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider }, + time: { start: input.time.ran ?? input.time.created }, + }, + } + } + if (input.state.status === "completed") { + return { + ...base, + state: { + status: "completed", + input: input.state.input, + output: outputText(input.state.content), + title: input.name, + metadata: { + structured: input.state.structured, + content: input.state.content, + outputPaths: input.state.outputPaths, + result: input.state.result, + providerCall: input.provider, + }, + time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + }, + } + } + return { + ...base, + state: { + status: "error", + input: input.state.input, + error: input.state.error.message, + metadata: { + structured: input.state.structured, + content: input.state.content, + result: input.state.result, + providerCall: input.provider, + }, + time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + }, + } +} + +export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit { + const status = part.state.status + const text = + status === "running" + ? part.tool === "task" + ? "running task" + : `running ${part.tool}` + : status === "completed" + ? part.state.output + : status === "error" + ? part.state.error + : "" + return { + kind: "tool", + source: "tool", + text, + phase, + messageID: part.messageID, + partID: part.id, + tool: part.tool, + part, + toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running", + toolError: status === "error" ? part.state.error : undefined, + } +} + +type Frame = { + key: string + commit: StreamCommit +} + +type ToolTrack = { + name: string + input: Record + started: number +} + +type ChildState = { + sessionID: string + label: string + description: string + status: FooterSubagentTab["status"] + background: boolean + title?: string + callIDs: Set + lastUpdatedAt: number + frames: Frame[] + text: Map + projectedText: Map + reasoning: Map + projectedReasoning: Map + tools: Map + finishedTools: Set + messageIDs: Set + hydrated: boolean +} + +export type SubagentTrackerInput = { + sdk: OpencodeClient + sessionID: string + thinking: boolean + emit: () => void +} + +export type SubagentTracker = { + main(event: V2Event): void + foreign(sessionID: string, event: V2Event): void + hydrate(next: { messages: SessionMessage[]; active: Record }): Promise + select(sessionID: string | undefined): void + snapshot(): FooterSubagentState +} + +function record(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record + return undefined +} + +function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const next = value.trim() + return next || undefined +} + +function childSessionID(structured: Record | undefined) { + const sessionID = text(structured?.sessionID) + if (!sessionID || !sessionID.startsWith("ses")) return undefined + const status = structured?.status + if (status !== "running" && status !== "completed") return undefined + return { sessionID, running: status === "running" } +} + +function tab(child: ChildState): FooterSubagentTab { + return { + sessionID: child.sessionID, + partID: `subagent:${child.sessionID}`, + callID: `subagent:${child.sessionID}`, + label: child.label, + description: child.description || child.title || "", + status: child.status, + background: child.background ? true : undefined, + title: child.title, + toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined, + lastUpdatedAt: child.lastUpdatedAt, + } +} + +export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker { + const children = new Map() + // Live subagent tool calls in the parent, so tool.success structured output + // can be joined with the call's input metadata. + const pendingCalls = new Map>() + // Foreign sessions already resolved through session.get. Non-children stay + // cached so unrelated concurrent sessions are checked at most once. + const checked = new Set() + // Foreign events buffered while a session.get discovery is in flight, so a + // fast child (including its settled event) is not lost mid-discovery. + const pendingEvents = new Map() + const hydrations = new Map>() + let selected: string | undefined + + const ensureChild = (sessionID: string): ChildState => { + const existing = children.get(sessionID) + const child: ChildState = existing ?? { + sessionID, + label: FALLBACK_LABEL, + description: "", + status: "running", + background: false, + callIDs: new Set(), + lastUpdatedAt: Date.now(), + frames: [], + text: new Map(), + projectedText: new Map(), + reasoning: new Map(), + projectedReasoning: new Map(), + tools: new Map(), + finishedTools: new Set(), + messageIDs: new Set(), + hydrated: false, + } + if (!existing) children.set(sessionID, child) + // Adopting a child while its session.get discovery is still in flight: + // drain the buffered events now. They arrived before whatever the caller + // applies next, so replaying them first preserves bus order, and the + // resolved discovery can no longer replay stale events (e.g. step.started) + // after a terminal settled event was applied directly. + const buffered = pendingEvents.get(sessionID) + if (buffered) { + pendingEvents.delete(sessionID) + for (const event of buffered) reduce(child, event) + } + return child + } + + const touch = (child: ChildState, timestamp?: number) => { + child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now()) + } + + const notifyDetail = (child: ChildState) => { + if (child.sessionID === selected) input.emit() + } + + const setFrame = (child: ChildState, key: string, commit: StreamCommit) => { + const index = child.frames.findIndex((item) => item.key === key) + if (index === -1) { + child.frames.push({ key, commit }) + if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT) + return + } + child.frames[index] = { key, commit } + } + + const applyMeta = (child: ChildState, meta: Record | undefined) => { + if (!meta) return + const agent = text(meta.agent) + if (agent) child.label = Locale.titlecase(agent) + const description = text(meta.description) + if (description) child.description = description + if (meta.background === true) child.background = true + } + + const userFrame = (child: ChildState, messageID: string, value: string) => { + if (child.messageIDs.has(messageID)) return false + child.messageIDs.add(messageID) + setFrame(child, `user:${messageID}`, { + kind: "user", + source: "system", + text: value, + phase: "start", + messageID, + }) + return true + } + + const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => { + const part = legacyTool({ + sessionID: child.sessionID, + messageID, + callID: item.id, + name: item.name, + state: item.state, + time: item.time, + provider: item.provider, + }) + if (item.state.status === "pending") return + child.callIDs.add(item.id) + if (item.state.status === "running") { + setFrame(child, `tool:${item.id}`, toolCommit(part, "start")) + return + } + child.finishedTools.add(item.id) + child.tools.delete(item.id) + setFrame(child, `tool:${item.id}`, toolCommit(part, "final")) + } + + const rebuild = (child: ChildState, messages: SessionMessage[]) => { + child.frames = [] + child.text.clear() + child.projectedText.clear() + child.reasoning.clear() + child.projectedReasoning.clear() + child.finishedTools.clear() + child.messageIDs.clear() + child.callIDs.clear() + for (const message of messages) { + if (message.type === "user") { + userFrame(child, message.id, message.text) + continue + } + if (message.type !== "assistant") continue + child.messageIDs.add(message.id) + for (const item of message.content) { + if (item.type === "text") { + child.text.set(item.id, item.text) + child.projectedText.set(item.id, item.text) + setFrame(child, `text:${item.id}`, { + kind: "assistant", + source: "assistant", + text: item.text, + phase: "progress", + messageID: message.id, + partID: item.id, + }) + continue + } + if (item.type === "reasoning") { + child.reasoning.set(item.id, item.text) + child.projectedReasoning.set(item.id, item.text) + if (input.thinking) + setFrame(child, `reasoning:${item.id}`, { + kind: "reasoning", + source: "reasoning", + text: `Thinking: ${item.text}`, + phase: "progress", + messageID: message.id, + partID: item.id, + }) + continue + } + childTool(child, item, message.id) + } + if (message.error) { + setFrame(child, `error:${message.id}`, { + kind: "error", + source: "system", + text: message.error.message, + phase: "start", + messageID: message.id, + }) + } + } + } + + const hydrateChild = (child: ChildState): Promise => { + const existing = hydrations.get(child.sessionID) + if (existing) return existing + const task = input.sdk.v2.session + .messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true }) + .then((response) => { + rebuild(child, response.data.data.toReversed()) + child.hydrated = true + notifyDetail(child) + }) + .catch(() => {}) + .finally(() => { + hydrations.delete(child.sessionID) + }) + hydrations.set(child.sessionID, task) + return task + } + + const discover = (sessionID: string) => { + if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return + checked.add(sessionID) + if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, []) + void input.sdk.v2.session + .get({ sessionID }, { throwOnError: true }) + .then((response) => { + const session = response.data.data + const buffered = pendingEvents.get(sessionID) ?? [] + pendingEvents.delete(sessionID) + if (session.parentID !== input.sessionID) return + const child = ensureChild(sessionID) + if (session.agent) child.label = Locale.titlecase(session.agent) + child.title = session.title + for (const event of buffered) reduce(child, event) + touch(child) + input.emit() + void hydrateChild(child) + }) + .catch(() => { + // Allow a later event to retry discovery after transient failures. + pendingEvents.delete(sessionID) + checked.delete(sessionID) + }) + } + + const reduce = (child: ChildState, event: V2Event) => { + if (event.type === "session.next.prompted") { + if (userFrame(child, event.data.messageID, event.data.prompt.text)) { + touch(child, event.data.timestamp) + notifyDetail(child) + } + return + } + if (event.type === "session.next.step.started") { + touch(child, event.data.timestamp) + if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) + if (child.status !== "running") child.status = "running" + input.emit() + return + } + if (event.type === "session.next.text.delta") { + const projected = child.projectedText.get(event.data.textID) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length)) + return + } + const next = (child.text.get(event.data.textID) ?? "") + event.data.delta + child.text.set(event.data.textID, next) + setFrame(child, `text:${event.data.textID}`, { + kind: "assistant", + source: "assistant", + text: next, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.text.ended") { + child.text.set(event.data.textID, event.data.text) + child.projectedText.delete(event.data.textID) + setFrame(child, `text:${event.data.textID}`, { + kind: "assistant", + source: "assistant", + text: event.data.text, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.reasoning.delta") { + const projected = child.projectedReasoning.get(event.data.reasoningID) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length)) + return + } + const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta + child.reasoning.set(event.data.reasoningID, next) + if (!input.thinking) return + setFrame(child, `reasoning:${event.data.reasoningID}`, { + kind: "reasoning", + source: "reasoning", + text: `Thinking: ${next}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }) + notifyDetail(child) + return + } + if (event.type === "session.next.reasoning.ended") { + child.reasoning.set(event.data.reasoningID, event.data.text) + child.projectedReasoning.delete(event.data.reasoningID) + if (!input.thinking) return + setFrame(child, `reasoning:${event.data.reasoningID}`, { + kind: "reasoning", + source: "reasoning", + text: `Thinking: ${event.data.text}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }) + notifyDetail(child) + return + } + if (event.type === "session.next.tool.input.started") { + child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp }) + return + } + if (event.type === "session.next.tool.called") { + const current = child.tools.get(event.data.callID) + child.tools.set(event.data.callID, { + name: event.data.tool, + input: event.data.input, + started: current?.started ?? event.data.timestamp, + }) + childTool( + child, + { + type: "tool", + id: event.data.callID, + name: event.data.tool, + provider: event.data.provider, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, + }, + event.data.assistantMessageID, + ) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { + if (child.finishedTools.has(event.data.callID)) return + const current = child.tools.get(event.data.callID) + const failed = event.type === "session.next.tool.failed" + childTool( + child, + { + type: "tool", + id: event.data.callID, + name: current?.name ?? "tool", + provider: event.data.provider, + state: failed + ? { + status: "error", + input: current?.input ?? {}, + structured: {}, + content: [], + error: event.data.error, + result: event.data.result, + } + : { + status: "completed", + input: current?.input ?? {}, + structured: event.data.structured, + content: event.data.content, + outputPaths: event.data.outputPaths, + result: event.data.result, + }, + time: { + created: current?.started ?? event.data.timestamp, + ran: current?.started, + completed: event.data.timestamp, + }, + }, + event.data.assistantMessageID, + ) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.step.failed") { + setFrame(child, `error:step:${event.data.assistantMessageID}`, { + kind: "error", + source: "system", + text: event.data.error.message, + phase: "start", + messageID: event.data.assistantMessageID, + }) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.execution.settled") { + child.status = + event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" + touch(child, event.data.timestamp) + input.emit() + } + } + + const mainTool = (item: SessionMessageAssistantTool, active?: Record) => { + if (item.name !== "subagent" || item.state.status !== "completed") return + const found = childSessionID(record(item.state.structured)) + if (!found) return + const child = ensureChild(found.sessionID) + applyMeta(child, record(item.state.input)) + if (found.running) child.background = true + if (child.status === "running") { + const running = found.running && (!active || found.sessionID in active) + child.status = running ? "running" : "completed" + } + touch(child, item.time.completed ?? item.time.created) + } + + return { + main(event) { + if (event.type === "session.next.tool.called") { + if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) + return + } + if (event.type === "session.next.tool.failed") { + pendingCalls.delete(event.data.callID) + return + } + if (event.type !== "session.next.tool.success") return + const pending = pendingCalls.get(event.data.callID) + pendingCalls.delete(event.data.callID) + const found = childSessionID(record(event.data.structured)) + if (!found) return + const child = ensureChild(found.sessionID) + applyMeta(child, pending) + if (found.running) { + child.background = true + child.status = "running" + } + if (!found.running && child.status === "running") child.status = "completed" + touch(child, event.data.timestamp) + input.emit() + if (!child.hydrated) void hydrateChild(child) + }, + foreign(sessionID, event) { + const child = children.get(sessionID) + if (child) { + reduce(child, event) + return + } + discover(sessionID) + const buffered = pendingEvents.get(sessionID) + if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event) + }, + async hydrate(next) { + for (const message of next.messages) { + if (message.type !== "assistant") continue + for (const item of message.content) { + if (item.type === "tool") mainTool(item, next.active) + } + } + // Family index: adopt children directly from the current session list so + // historical subagents beyond the projected message window still get tabs. + const family = await input.sdk.v2.session + .list({ limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true }) + .then((response) => response.data.data.filter((session) => session.parentID === input.sessionID)) + .catch(() => []) + for (const session of family) { + const child = ensureChild(session.id) + if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent) + if (!child.title) child.title = session.title + touch(child, session.time.updated) + } + for (const sessionID of Object.keys(next.active)) discover(sessionID) + for (const child of children.values()) { + // Reconnect can miss a child's settled event; the active map is the + // authoritative live signal for still-running children. + if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed" + } + const current = selected ? children.get(selected) : undefined + if (current) await hydrateChild(current) + if (children.size > 0) input.emit() + }, + select(sessionID) { + selected = sessionID + const child = sessionID ? children.get(sessionID) : undefined + if (child && !child.hydrated) void hydrateChild(child) + input.emit() + }, + snapshot() { + const tabs = [...children.values()].map(tab).toSorted((a, b) => { + const active = Number(b.status === "running") - Number(a.status === "running") + if (active !== 0) return active + return b.lastUpdatedAt - a.lastUpdatedAt + }) + const child = selected ? children.get(selected) : undefined + const details: Record = child + ? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } } + : {} + return { tabs, details, permissions: [], questions: [] } + }, + } +} diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts new file mode 100644 index 0000000000..7700ddd024 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts @@ -0,0 +1,798 @@ +import type { + OpencodeClient, + PermissionRequest, + PermissionV2Request, + QuestionRequest, + QuestionV2Request, + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantTool, + V2Event, +} from "@opencode-ai/sdk/v2" +import { blockerStatus, pickBlockerView } from "./session-data" +import { writeSessionOutput } from "./stream" +import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent" +import type { + FooterApi, + FooterView, + LocalReplayAnchor, + LocalReplayRow, + RunFilePart, + RunInput, + RunPrompt, + RunPromptPart, + RunProvider, + StreamCommit, +} from "./types" + +type Trace = { + write(type: string, data?: unknown): void +} + +type StreamInput = { + sdk: OpencodeClient + directory?: string + sessionID: string + thinking: boolean + replay?: boolean + replayLimit?: number + limits: () => Record + providers?: () => RunProvider[] + footer: FooterApi + trace?: Trace + signal?: AbortSignal +} + +export type SessionTurnInput = { + agent: string | undefined + model: RunInput["model"] + variant: string | undefined + prompt: RunPrompt + files: RunFilePart[] + includeFiles: boolean + onVisibleOutput?: (anchor: LocalReplayAnchor) => void + signal?: AbortSignal +} + +export type SessionResizeReplayInput = { + localRows: () => LocalReplayRow[] + reset: () => Promise +} + +export type SessionTransport = { + runPromptTurn(input: SessionTurnInput): Promise + interruptActiveTurn(): Promise + selectSubagent(sessionID: string | undefined): void + replayOnResize(input: SessionResizeReplayInput): Promise + close(): Promise +} + +type Wait = { + messageID: string + promoted: boolean + interrupted: boolean + failureRendered: boolean + resolve: () => void + reject: (error: unknown) => void + onVisibleOutput?: (anchor: LocalReplayAnchor) => void +} + +type RunV2Event = V2Event +type PromptFilePart = Extract + +type ToolState = { + messageID: string + name: string + input: Record + started: number + running: boolean +} + +type State = { + permissions: PermissionRequest[] + questions: QuestionRequest[] + view: FooterView + messageIDs: Set + text: Map + projectedText: Map + reasoning: Map + projectedReasoning: Map + tools: Map + finishedTools: Set + wait?: Wait + connected: boolean + closed: boolean + initial: boolean + buffered?: RunV2Event[] + errors: Set +} + +const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) + +export function formatUnknownError(error: unknown): string { + if (typeof error === "string") return error + if (error instanceof Error) return error.message || error.name + if (error && typeof error === "object") { + const message = Reflect.get(error, "message") + if (typeof message === "string" && message.trim()) return message + const tag = Reflect.get(error, "_tag") + if (typeof tag === "string" && tag.trim()) return tag + } + return "unknown error" +} + +function permission(request: PermissionV2Request): PermissionRequest { + return { + id: request.id, + sessionID: request.sessionID, + permission: request.action, + patterns: request.resources, + metadata: request.metadata ?? {}, + always: request.save ?? [], + tool: request.source?.type === "tool" ? request.source : undefined, + } +} + +function question(request: QuestionV2Request): QuestionRequest { + return { + id: request.id, + sessionID: request.sessionID, + questions: request.questions, + tool: request.tool, + } +} + +function sessionID(event: RunV2Event) { + return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined +} + +function errorMessage(error: { message?: string; _tag?: string }) { + return error.message || error._tag || "Session execution failed" +} + +function wait(delay: number, signal: AbortSignal) { + return new Promise((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) +} + +async function prepareFile(file: RunFilePart) { + if (file.mime !== "text/plain") return { attachment: { uri: file.url, mime: file.mime, name: file.filename } } + const content = file.url.startsWith("data:") + ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8") + : await Bun.file(new URL(file.url)).text() + return { text: `\n${content}\n` } +} + +function promptFileSource(part: PromptFilePart) { + if (!part.source?.text) return + return { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } +} + +function streamPartKey(messageID: string, partID: string) { + return `${messageID}\u0000${partID}` +} + +async function resolveSelectedModel(input: StreamInput, next: Pick) { + if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } + if (!next.variant) return + + const session = await input.sdk.v2.session + .get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal }) + .then((response) => response.data.data.model) + if (session) return { ...session, variant: next.variant } + + const fallback = await input.sdk.v2.model + .default(undefined, { throwOnError: true, signal: next.signal }) + .then((response) => response.data.data) + if (!fallback) return + return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } +} + +export async function createSessionTransport(input: StreamInput): Promise { + const controller = new AbortController() + input.signal?.addEventListener("abort", () => controller.abort(), { once: true }) + const state: State = { + permissions: [], + questions: [], + view: { type: "prompt" }, + messageIDs: new Set(), + text: new Map(), + projectedText: new Map(), + reasoning: new Map(), + projectedReasoning: new Map(), + tools: new Map(), + finishedTools: new Set(), + connected: false, + closed: false, + initial: true, + errors: new Set(), + } + let readyResolve!: () => void + let readyReject!: (error: unknown) => void + const ready = new Promise((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) + const abortReady = () => readyReject(new Error("Mini closed before the event stream connected")) + controller.signal.addEventListener("abort", abortReady, { once: true }) + const offFooterClose = input.footer.onClose(() => controller.abort()) + + const subagents = createSubagentTracker({ + sdk: input.sdk, + sessionID: input.sessionID, + thinking: input.thinking, + emit: () => { + if (state.closed || input.footer.isClosed) return + writeSessionOutput( + { footer: input.footer, trace: input.trace }, + { commits: [], footer: { subagent: subagents.snapshot() } }, + ) + }, + }) + + const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => { + const visible = commits.at(-1) + if (visible) { + state.wait?.onVisibleOutput?.({ + kind: visible.kind, + text: visible.text, + phase: visible.phase, + messageID: visible.messageID, + partID: visible.partID, + toolState: visible.toolState, + }) + } + writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits, footer: patch ? { patch } : undefined }) + } + + const syncBlockers = () => { + const next = pickBlockerView({ permission: state.permissions[0], question: state.questions[0] }) + if (next.type === "prompt" && state.view.type === "prompt") return + if (next.type !== "prompt" && state.view.type === next.type && next.request.id === state.view.request.id) return + state.view = next + writeSessionOutput( + { footer: input.footer, trace: input.trace }, + { commits: [], footer: { view: next, patch: { status: blockerStatus(next) } } }, + ) + } + + const renderTool = (messageID: string, item: SessionMessageAssistantTool) => { + const part = legacyTool({ + sessionID: input.sessionID, + messageID, + callID: item.id, + name: item.name, + state: item.state, + time: item.time, + provider: item.provider, + }) + if (item.state.status === "pending") return + if (item.state.status === "running") { + if (state.tools.get(item.id)?.running) return + state.tools.set(item.id, { + messageID, + name: item.name, + input: item.state.input, + started: item.time.ran ?? item.time.created, + running: true, + }) + write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` }) + return + } + if (state.finishedTools.has(item.id)) return + if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")]) + state.finishedTools.add(item.id) + state.tools.delete(item.id) + write([toolCommit(part, item.state.status === "completed" && part.state.status === "completed" && part.state.output ? "progress" : "final")]) + } + + const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => { + if (message.type === "user") { + const waiting = state.wait?.messageID === message.id + if (waiting && state.wait) state.wait.promoted = true + if (!render || state.messageIDs.has(message.id)) return + state.messageIDs.add(message.id) + if (reuseVisibleWait && waiting) return + write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }]) + return + } + if (message.type !== "assistant") return + state.messageIDs.add(message.id) + for (const item of message.content) { + if (item.type === "text") { + const key = streamPartKey(message.id, item.id) + const sent = state.text.get(key)?.length ?? 0 + state.text.set(key, item.text) + if (render) state.projectedText.set(key, item.text) + if (render && item.text.length > sent) + write([ + { + kind: "assistant", + source: "assistant", + text: item.text.slice(sent), + phase: "progress", + messageID: message.id, + partID: item.id, + }, + ]) + continue + } + if (item.type === "reasoning") { + const key = streamPartKey(message.id, item.id) + const sent = state.reasoning.get(key)?.length ?? 0 + state.reasoning.set(key, item.text) + if (render) state.projectedReasoning.set(key, item.text) + if (render && input.thinking && item.text.length > sent) + write([ + { + kind: "reasoning", + source: "reasoning", + text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent), + phase: "progress", + messageID: message.id, + partID: item.id, + }, + ]) + continue + } + if (render) renderTool(message.id, item) + } + if (render && message.error && !state.errors.has(message.id)) { + state.errors.add(message.id) + write([ + { + kind: "error", + source: "system", + text: errorMessage(message.error), + phase: "start", + messageID: message.id, + }, + ]) + } + } + + const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => { + const [messages, permissions, questions, active] = await Promise.all([ + input.sdk.v2.session.messages( + { sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, + { throwOnError: true }, + ), + input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }), + input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }), + input.sdk.v2.session.active({ throwOnError: true }), + ]) + const projected = messages.data.data.toReversed() + for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) + state.permissions = permissions.data.data.map(permission) + state.questions = questions.data.data.map(question) + syncBlockers() + await subagents.hydrate({ messages: projected, active: active.data.data }) + const running = input.sessionID in active.data.data + write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" }) + if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) { + const current = state.wait + state.wait = undefined + current.resolve() + } + } + + const apply = (event: RunV2Event) => { + const source = sessionID(event) + if (source !== input.sessionID) { + if (source) subagents.foreign(source, event) + return + } + input.trace?.write("recv.event", event) + subagents.main(event) + if (event.type === "session.next.prompted") { + if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true + state.messageIDs.add(event.data.messageID) + write([], { phase: "running", status: "waiting for assistant" }) + return + } + if (event.type === "session.next.step.started") { + write([], { phase: "running", status: "assistant responding" }) + return + } + if (event.type === "session.next.text.delta") { + const key = streamPartKey(event.data.assistantMessageID, event.data.textID) + const projected = state.projectedText.get(key) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + state.projectedText.set(key, projected.slice(covered + event.data.delta.length)) + return + } + const previous = state.text.get(key) ?? "" + state.text.set(key, previous + event.data.delta) + write([ + { + kind: "assistant", + source: "assistant", + text: event.data.delta, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }, + ]) + return + } + if (event.type === "session.next.text.ended") { + const key = streamPartKey(event.data.assistantMessageID, event.data.textID) + const previous = state.text.get(key) ?? "" + if (event.data.text.length > previous.length) + write([ + { + kind: "assistant", + source: "assistant", + text: event.data.text.slice(previous.length), + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }, + ]) + state.text.set(key, event.data.text) + state.projectedText.delete(key) + return + } + if (event.type === "session.next.reasoning.delta") { + const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) + const projected = state.projectedReasoning.get(key) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + state.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length)) + return + } + const previous = state.reasoning.get(key) ?? "" + state.reasoning.set(key, previous + event.data.delta) + if (input.thinking) + write([ + { + kind: "reasoning", + source: "reasoning", + text: previous ? event.data.delta : `Thinking: ${event.data.delta}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }, + ]) + return + } + if (event.type === "session.next.reasoning.ended") { + const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) + const previous = state.reasoning.get(key) ?? "" + if (input.thinking && event.data.text.length > previous.length) + write([ + { + kind: "reasoning", + source: "reasoning", + text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }, + ]) + state.reasoning.set(key, event.data.text) + state.projectedReasoning.delete(key) + return + } + if (event.type === "session.next.tool.input.started") { + state.tools.set(event.data.callID, { + messageID: event.data.assistantMessageID, + name: event.data.name, + input: {}, + started: event.data.timestamp, + running: false, + }) + return + } + if (event.type === "session.next.tool.called") { + if (state.finishedTools.has(event.data.callID)) return + const current = state.tools.get(event.data.callID) + const item: SessionMessageAssistantTool = { + type: "tool", + id: event.data.callID, + name: event.data.tool, + provider: event.data.provider, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, + } + renderTool(event.data.assistantMessageID, item) + return + } + if (event.type === "session.next.tool.progress") return + if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { + const current = state.tools.get(event.data.callID) + const failed = event.type === "session.next.tool.failed" + const item: SessionMessageAssistantTool = { + type: "tool", + id: event.data.callID, + name: current?.name ?? "tool", + provider: event.data.provider, + state: failed + ? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result } + : { + status: "completed", + input: current?.input ?? {}, + structured: event.data.structured, + content: event.data.content, + outputPaths: event.data.outputPaths, + result: event.data.result, + }, + time: { created: current?.started ?? event.data.timestamp, ran: current?.started, completed: event.data.timestamp }, + } + renderTool(event.data.assistantMessageID, item) + return + } + if (event.type === "permission.v2.asked") { + if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data)) + syncBlockers() + return + } + if (event.type === "permission.v2.replied") { + state.permissions = state.permissions.filter((item) => item.id !== event.data.requestID) + syncBlockers() + return + } + if (event.type === "question.v2.asked") { + if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data)) + syncBlockers() + return + } + if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") { + state.questions = state.questions.filter((item) => item.id !== event.data.requestID) + syncBlockers() + return + } + if (event.type === "session.next.step.ended") { + const total = + event.data.tokens.input + + event.data.tokens.output + + event.data.tokens.reasoning + + event.data.tokens.cache.read + + event.data.tokens.cache.write + const usage = total > 0 ? total.toLocaleString() : "" + write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage }) + return + } + if (event.type === "session.next.step.failed") { + state.errors.add(event.data.assistantMessageID) + if (state.wait) state.wait.failureRendered = true + write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }]) + return + } + if (event.type === "session.next.execution.settled") { + write([], { phase: "idle", status: "" }) + const current = state.wait + if (!current || (!current.promoted && !current.interrupted)) return + state.wait = undefined + if (current.interrupted) { + current.resolve() + return + } + if (event.data.outcome === "failure") { + if (current.failureRendered) { + current.resolve() + return + } + current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed")) + return + } + current.resolve() + } + } + + const receive = (event: RunV2Event) => { + if (state.buffered) { + state.buffered.push(event) + return + } + apply(event) + } + + const connect = async () => { + while (!controller.signal.aborted && !input.footer.isClosed) { + const error = await (async () => { + const connection = new AbortController() + const abortConnection = () => connection.abort() + controller.signal.addEventListener("abort", abortConnection, { once: true }) + const response = await input.sdk.v2.event.subscribe({ + signal: connection.signal, + sseMaxRetryAttempts: 0, + throwOnError: true, + }) + const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator + try { + const first = await stream.next() + if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") + const buffered: RunV2Event[] = [] + let booting = true + const consume = (async () => { + while (!connection.signal.aborted) { + const next = await stream.next() + if (next.done) throw new Error("Event stream disconnected") + if (booting) buffered.push(next.value) + else receive(next.value) + } + })() + void consume.catch(() => {}) + await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial }) + state.initial = false + booting = false + for (const event of buffered.splice(0)) apply(event) + state.connected = true + readyResolve() + await consume + } finally { + controller.signal.removeEventListener("abort", abortConnection) + connection.abort() + void stream.return?.(undefined).catch(() => {}) + } + })().catch((error) => error) + state.connected = false + if (controller.signal.aborted || input.footer.isClosed) return + input.trace?.write("recv.reconnect", { error: formatUnknownError(error) }) + write([], { phase: "running", status: "reconnecting" }) + await wait(250, controller.signal) + } + } + const connection = connect() + try { + await ready + } catch (error) { + offFooterClose() + throw error + } finally { + controller.signal.removeEventListener("abort", abortReady) + } + + return { + async runPromptTurn(next) { + if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts") + if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts") + if (state.wait) throw new Error("prompt already running") + if (!state.connected) throw new Error("Event stream is reconnecting") + + if (next.agent) { + await input.sdk.v2.session.switchAgent( + { sessionID: input.sessionID, agent: next.agent }, + { throwOnError: true, signal: next.signal }, + ) + } + const selected = await resolveSelectedModel(input, next) + if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") + if (selected) + await input.sdk.v2.session.switchModel( + { sessionID: input.sessionID, model: selected }, + { throwOnError: true, signal: next.signal }, + ) + + const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile)) + const promptFiles = next.prompt.parts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: promptFileSource(part), + }, + ] + : [], + ) + const attachments = [ + ...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), + ...promptFiles, + ] + const agents = next.prompt.parts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ) + const messageID = next.prompt.messageID + if (!messageID) throw new Error("Prompt message ID is required") + let resolve!: () => void + let reject!: (error: unknown) => void + const done = new Promise((done, fail) => { + resolve = done + reject = fail + }) + const active: Wait = { + messageID, + promoted: false, + interrupted: false, + failureRendered: false, + resolve, + reject, + onVisibleOutput: next.onVisibleOutput, + } + state.wait = active + const interrupt = () => { + active.interrupted = true + void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + next.signal?.addEventListener("abort", interrupt, { once: true }) + try { + input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) + await input.sdk.v2.session.prompt( + { + sessionID: input.sessionID, + id: messageID, + prompt: { + text: [ + next.prompt.text, + ...prepared.flatMap((file) => (file.text ? [file.text] : [])), + ].join("\n\n"), + files: attachments.length ? attachments : undefined, + agents: agents.length ? agents : undefined, + }, + delivery: "steer", + }, + { throwOnError: true, signal: next.signal }, + ) + await done + } catch (error) { + if (state.wait === active) state.wait = undefined + if (next.signal?.aborted) return + throw error + } finally { + next.signal?.removeEventListener("abort", interrupt) + } + }, + async interruptActiveTurn() { + if (state.wait) state.wait.interrupted = true + await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + }, + selectSubagent(sessionID) { + subagents.select(sessionID) + }, + async replayOnResize(next) { + if (!input.replay || state.closed || input.footer.isClosed) return false + const buffered: RunV2Event[] = [] + state.buffered = buffered + try { + await input.footer.idle() + await next.reset() + state.messageIDs.clear() + state.text.clear() + state.projectedText.clear() + state.reasoning.clear() + state.projectedReasoning.clear() + state.tools.clear() + state.finishedTools.clear() + state.errors.clear() + await hydrate({ render: true, reuseVisibleWait: false }) + } finally { + state.buffered = undefined + } + for (const event of buffered) apply(event) + for (const row of next.localRows()) { + if (row.commit.messageID && state.messageIDs.has(row.commit.messageID)) continue + input.footer.append(row.commit) + } + return true + }, + async close() { + state.closed = true + offFooterClose() + controller.abort() + void connection.catch(() => {}) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts deleted file mode 100644 index e4817f514d..0000000000 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ /dev/null @@ -1,1462 +0,0 @@ -// Global event subscription and prompt turn coordination. -// -// Creates a long-lived global event stream subscription and feeds relevant -// events for the current session tree through the reducers. The reducers -// produce scrollback commits and footer patches, which get forwarded to the -// footer through stream.ts. -// -// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt, arms a -// deferred Wait, and resolves when the session becomes idle. -// Prefer session.status idle events, but also poll session.status because some -// transports can miss status events while still delivering message events. If -// the turn is aborted (user interrupt), it flushes any in-progress parts as -// interrupted entries. -// -// The tick counter prevents stale idle events from resolving the wrong turn. -// We also re-check live session status before resolving an idle event so a -// delayed idle from an older turn cannot complete a newer busy turn. -import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" -import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" -import { makeRuntime } from "@/effect/run-service" -import { - blockerStatus, - bootstrapSessionData, - createSessionData, - flushInterrupted, - pickBlockerView, - reduceSessionData, - type SessionData, -} from "./session-data" -import { replayActiveText, replayLocalRows, replaySession } from "./session-replay" -import { - bootstrapSubagentCalls, - bootstrapSubagentData, - createSubagentData, - listSubagentPermissions, - listSubagentQuestions, - listSubagentTabs, - reduceSubagentData, - sameSubagentTab, - snapshotSelectedSubagentData, - SUBAGENT_BOOTSTRAP_LIMIT, - SUBAGENT_CALL_BOOTSTRAP_LIMIT, - type SubagentData, -} from "./subagent-data" -import { traceFooterOutput, writeSessionOutput } from "./stream" -import type { - FooterApi, - FooterOutput, - FooterPatch, - FooterSubagentState, - FooterSubagentTab, - FooterView, - LocalReplayAnchor, - LocalReplayRow, - RunFilePart, - RunInput, - RunPrompt, - RunPromptPart, - RunProvider, - StreamCommit, -} from "./types" - -type Trace = { - write(type: string, data?: unknown): void -} - -const StreamClosed = undefined as never - -type StreamInput = { - sdk: OpencodeClient - directory?: string - sessionID: string - thinking: boolean - replay?: boolean - replayLimit?: number - limits: () => Record - providers?: () => RunProvider[] - footer: FooterApi - trace?: Trace - signal?: AbortSignal -} - -type Wait = { - tick: number - armed: boolean - live: boolean - onVisibleOutput?: (anchor: LocalReplayAnchor) => void - done: Deferred.Deferred -} - -export type SessionTurnInput = { - agent: string | undefined - model: RunInput["model"] - variant: string | undefined - prompt: RunPrompt - files: RunFilePart[] - includeFiles: boolean - onVisibleOutput?: (anchor: LocalReplayAnchor) => void - signal?: AbortSignal -} - -export type SessionTransport = { - runPromptTurn(input: SessionTurnInput): Promise - selectSubagent(sessionID: string | undefined): void - replayOnResize(input: SessionResizeReplayInput): Promise - close(): Promise -} - -export type SessionResizeReplayInput = { - localRows: () => LocalReplayRow[] - reset: () => Promise -} - -type State = { - data: SessionData - subagent: SubagentData - wait?: Wait - tick: number - fault?: unknown - footerView: FooterView - blockerTick: number - selectedSubagent?: string - blockers: Map -} - -type TransportService = { - readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect - readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect - readonly replayOnResize: (input: SessionResizeReplayInput) => Effect.Effect - readonly close: () => Effect.Effect -} - -class Service extends Context.Service()("@opencode/RunStreamTransport") {} - -function sid(event: Event): string | undefined { - if (event.type === "message.updated") { - return event.properties.sessionID - } - - if (event.type === "message.part.delta") { - return event.properties.sessionID - } - - if (event.type === "message.part.updated") { - return event.properties.part.sessionID - } - - if ( - event.type === "session.next.shell.started" || - event.type === "session.next.shell.ended" || - event.type === "permission.asked" || - event.type === "permission.replied" || - event.type === "question.asked" || - event.type === "question.replied" || - event.type === "question.rejected" || - event.type === "session.error" || - event.type === "session.status" - ) { - return event.properties.sessionID - } - - return undefined -} - -function isEvent(value: unknown): value is Event { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false - } - - const type = Reflect.get(value, "type") - const properties = Reflect.get(value, "properties") - return typeof type === "string" && !!properties && typeof properties === "object" -} - -function isGlobalEvent(value: unknown): value is GlobalEvent { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false - } - - const payload = Reflect.get(value, "payload") - return !!payload && typeof payload === "object" -} - -function globalPayloadEvent(value: unknown): Event | undefined { - if (!isGlobalEvent(value)) { - return undefined - } - - const payload = value.payload - if (payload.type === "sync") { - return undefined - } - - return isEvent(payload) ? payload : undefined -} - -function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { - if (!directory || !isGlobalEvent(value)) { - return false - } - - if (value.directory !== directory) { - return false - } - - return value.payload.type === "server.instance.disposed" -} - -function active(event: Event, sessionID: string): boolean { - if (sid(event) !== sessionID) { - return false - } - - if (event.type === "message.updated") { - return event.properties.info.role === "assistant" - } - - if (event.type === "message.part.delta" || event.type === "message.part.updated") { - return false - } - - if (event.type !== "session.status") { - return true - } - - return event.properties.status.type !== "idle" -} - -// Races the turn's deferred completion against an abort signal. -function waitTurn(done: Wait["done"], signal: AbortSignal) { - return Effect.raceAll([ - Deferred.await(done).pipe(Effect.as("idle" as const), Effect.exit), - Effect.callback<"abort">((resume) => { - if (signal.aborted) { - resume(Effect.succeed("abort")) - return Effect.void - } - - const onAbort = () => { - signal.removeEventListener("abort", onAbort) - resume(Effect.succeed("abort")) - } - - signal.addEventListener("abort", onAbort, { once: true }) - return Effect.sync(() => signal.removeEventListener("abort", onAbort)) - }).pipe(Effect.exit), - ]).pipe(Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value)))) -} - -export function formatUnknownError(error: unknown): string { - if (typeof error === "string") { - return error - } - - if (error instanceof Error) { - return error.message || error.name - } - - if (error && typeof error === "object") { - const value = error as { message?: unknown; name?: unknown } - if (typeof value.message === "string" && value.message.trim()) { - return value.message - } - - if (typeof value.name === "string" && value.name.trim()) { - return value.name - } - } - - return "unknown error" -} - -function sameView(a: FooterView, b: FooterView) { - if (a.type !== b.type) { - return false - } - - if (a.type === "prompt" && b.type === "prompt") { - return true - } - - if (a.type === "prompt" || b.type === "prompt") { - return false - } - - return a.request === b.request -} - -function blockerOrder(order: Map, id: string) { - return order.get(id) ?? Number.MAX_SAFE_INTEGER -} - -function firstByOrder(left: T[], right: T[], order: Map) { - return [...left, ...right].sort((a, b) => { - const next = blockerOrder(order, a.id) - blockerOrder(order, b.id) - if (next !== 0) { - return next - } - - return a.id.localeCompare(b.id) - })[0] -} - -function pickView(data: SessionData, subagent: SubagentData, order: Map): FooterView { - return pickBlockerView({ - permission: firstByOrder(data.permissions, listSubagentPermissions(subagent), order), - question: firstByOrder(data.questions, listSubagentQuestions(subagent), order), - }) -} - -function composeFooter(input: { - patch?: FooterPatch - subagent?: FooterSubagentState - current: FooterView - previous: FooterView -}) { - let footer: FooterOutput | undefined - - if (input.subagent) { - footer = { - ...footer, - subagent: input.subagent, - } - } - - if (!sameView(input.previous, input.current)) { - footer = { - ...footer, - view: input.current, - } - } - - if (input.current.type !== "prompt") { - footer = { - ...footer, - patch: { - ...input.patch, - status: blockerStatus(input.current), - }, - } - return footer - } - - if (input.patch) { - footer = { - ...footer, - patch: input.patch, - } - return footer - } - - if (input.previous.type !== "prompt") { - footer = { - ...footer, - patch: { - status: "", - }, - } - } - - return footer -} - -function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) { - const before = new Map(prev.map((item) => [item.sessionID, item])) - const after = new Map(next.map((item) => [item.sessionID, item])) - - for (const [sessionID, tab] of after) { - if (sameSubagentTab(before.get(sessionID), tab)) { - continue - } - - trace?.write("subagent.tab", { - sessionID, - tab, - }) - } - - for (const sessionID of before.keys()) { - if (after.has(sessionID)) { - continue - } - - trace?.write("subagent.tab", { - sessionID, - cleared: true, - }) - } -} - -function createLayer(input: StreamInput) { - return Layer.fresh( - Layer.effect( - Service, - Effect.gen(function* () { - const scope = yield* Scope.make() - const abort = yield* Scope.provide(scope)( - Effect.acquireRelease( - Effect.sync(() => new AbortController()), - (abort) => Effect.sync(() => abort.abort()), - ), - ) - let closed = false - let closeStream = () => {} - const halt = () => { - abort.abort() - } - const stop = () => { - input.signal?.removeEventListener("abort", halt) - abort.abort() - closeStream() - } - const closeScope = () => { - if (closed) { - return Effect.void - } - - closed = true - stop() - return Scope.close(scope, Exit.void) - } - - input.signal?.addEventListener("abort", halt, { once: true }) - yield* Effect.addFinalizer(() => closeScope()) - - const events = yield* Scope.provide(scope)( - Effect.acquireRelease( - Effect.promise(() => - input.sdk.global.event({ - signal: abort.signal, - }), - ), - (events) => - Effect.sync(() => { - void events.stream.return(StreamClosed).catch(() => {}) - }), - ), - ) - closeStream = () => { - void events.stream.return(StreamClosed).catch(() => {}) - } - input.trace?.write("recv.subscribe", { - sessionID: input.sessionID, - }) - - const state: State = { - data: createSessionData(), - subagent: createSubagentData(), - tick: 0, - footerView: { type: "prompt" }, - blockerTick: 0, - blockers: new Map(), - } - let booting = true - let replaying = false - let replayDisabled = false - let replayPending: SessionResizeReplayInput | undefined - const buffered: Event[] = [] - const replayedParts = new Set() - const recovering = new Set() - const tracked = (sessionID: string | undefined) => - sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID)) - const currentSubagentState = () => { - if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) { - state.selectedSubagent = undefined - } - - return snapshotSelectedSubagentData(state.subagent, state.selectedSubagent) - } - - const seedBlocker = (id: string) => { - if (state.blockers.has(id)) { - return - } - - state.blockerTick += 1 - state.blockers.set(id, state.blockerTick) - } - - const trackBlocker = (event: Event) => { - if (event.type !== "permission.asked" && event.type !== "question.asked") { - return - } - - if (event.properties.sessionID !== input.sessionID && !state.subagent.tabs.has(event.properties.sessionID)) { - return - } - - seedBlocker(event.properties.id) - } - - const releaseBlocker = (event: Event) => { - if ( - event.type !== "permission.replied" && - event.type !== "question.replied" && - event.type !== "question.rejected" - ) { - return - } - - state.blockers.delete(event.properties.requestID) - } - - const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => { - const current = pickView(state.data, state.subagent, state.blockers) - const footer = composeFooter({ - patch, - subagent: nextSubagent, - current, - previous: state.footerView, - }) - - if (commits.length === 0 && !footer) { - state.footerView = current - return - } - - input.trace?.write("reduce.output", { - commits, - footer: traceFooterOutput(footer), - }) - writeSessionOutput( - { - footer: input.footer, - trace: input.trace, - }, - { - commits, - footer, - }, - ) - state.footerView = current - } - - const resolveShellAgent = Effect.fn("RunStreamTransport.resolveShellAgent")(function* ( - agent: string | undefined, - ) { - if (agent) { - return agent - } - - const list = yield* Effect.promise(() => - input.sdk.app.agents(input.directory ? { directory: input.directory } : undefined, { throwOnError: true }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ) - const next = list.find((item) => item.mode !== "subagent" && item.hidden !== true)?.name - if (next) { - return next - } - - return yield* Effect.fail(new Error("no primary agent available for shell mode")) - }) - - const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) { - if (recovering.has(partID)) { - return - } - - recovering.add(partID) - try { - while (!closed && !abort.signal.aborted && !input.footer.isClosed) { - if (state.data.questions.length > 0 || !state.data.tools.has(partID)) { - return - } - - const questions = yield* Effect.promise(() => input.sdk.question.list()).pipe( - Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)), - Effect.orElseSucceed(() => []), - ) - if (state.data.questions.length > 0 || !state.data.tools.has(partID)) { - return - } - - if (questions.length > 0) { - bootstrapSessionData({ - data: state.data, - messages: [], - permissions: [], - questions, - }) - for (const request of questions) { - seedBlocker(request.id) - } - input.trace?.write("question.recover", { - sessionID: input.sessionID, - requests: questions.map((request) => request.id), - }) - syncFooter([]) - return - } - - yield* Effect.sleep("250 millis") - } - } finally { - recovering.delete(partID) - } - }) - - const messages = (sessionID: string, limit?: number) => - Effect.promise(() => - input.sdk.session.messages({ - sessionID, - ...(typeof limit === "number" ? { limit } : {}), - }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ) - - const replayMessages = () => - Effect.promise(() => - input.sdk.session.messages({ - sessionID: input.sessionID, - ...(input.replayLimit === undefined - ? {} - : { limit: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) }), - }), - ).pipe(Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? [])))) - - const replayRequests = () => - Effect.all( - [ - Effect.promise(() => input.sdk.permission.list()).pipe( - Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))), - ), - Effect.promise(() => input.sdk.question.list()).pipe( - Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))), - ), - ], - { concurrency: "unbounded" }, - ) - - const markReplayedParts = (data: SessionData) => { - replayedParts.clear() - for (const [partID] of data.text) { - if (data.part.has(partID)) { - replayedParts.add(partID) - } - } - } - - const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* ( - sessions: string[], - ) { - yield* Effect.forEach( - sessions, - (sessionID) => - messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( - Effect.tap((messagesList) => - Effect.sync(() => { - if ( - !bootstrapSubagentCalls({ - data: state.subagent, - sessionID, - messages: messagesList, - thinking: input.thinking, - limits: input.limits(), - }) - ) { - return - } - - syncFooter([], undefined, currentSubagentState()) - }), - ), - ), - { - concurrency: 4, - discard: true, - }, - ) - }) - - const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () { - const [messagesList, children, permissions, questions] = yield* Effect.all( - [ - messages( - input.sessionID, - input.replay - ? input.replayLimit === undefined - ? undefined - : Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) - : SUBAGENT_BOOTSTRAP_LIMIT, - ), - Effect.promise(() => - input.sdk.session.children({ - sessionID: input.sessionID, - }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), - Effect.promise(() => input.sdk.permission.list()).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), - Effect.promise(() => input.sdk.question.list()).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), - ], - { - concurrency: "unbounded", - }, - ) - - const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID) - const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID) - const history = input.replay - ? replaySession({ - messages: messagesList, - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - : undefined - const replay = - history && input.replayLimit !== undefined && messagesList.length > input.replayLimit - ? replaySession({ - messages: messagesList.slice(-input.replayLimit), - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - : history - - if (history) { - state.data = history.data - } - - if (!history) { - bootstrapSessionData({ - data: state.data, - messages: messagesList, - permissions: sessionPermissions, - questions: sessionQuestions, - }) - } - - if (history) { - markReplayedParts(history.data) - } - - bootstrapSubagentData({ - data: state.subagent, - messages: messagesList, - children, - permissions, - questions, - }) - - for (const request of [ - ...state.data.permissions, - ...listSubagentPermissions(state.subagent), - ...state.data.questions, - ...listSubagentQuestions(state.subagent), - ].sort((a, b) => a.id.localeCompare(b.id))) { - seedBlocker(request.id) - } - - if (replay) { - const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools]) - for (const commit of replay.commits) { - input.trace?.write("ui.commit", commit) - input.footer.append(commit) - - if (commit.partID && activeCommitIDs.has(commit.partID)) { - continue - } - - yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined)) - } - } - - const snapshot = currentSubagentState() - traceTabs(input.trace, [], snapshot.tabs) - syncFooter([], replay?.patch, snapshot) - if (replay) { - yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined)) - } - - booting = false - yield* drainBuffered() - - const sessions = [...state.subagent.tabs.keys()] - if (sessions.length === 0) { - return - } - - yield* bootstrapSubagentHistory(sessions).pipe( - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - }) - - const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) => - Effect.promise(() => input.sdk.session.status()).pipe( - Effect.map((out) => { - const item = out.data?.[input.sessionID] - return !item || item.type === "idle" - }), - Effect.orElseSucceed(() => fallback), - ), - ) - - const fail = Effect.fn("RunStreamTransport.fail")(function* (error: unknown) { - if (state.fault) { - return - } - - state.fault = error - const next = state.wait - state.wait = undefined - if (!next) { - return - } - - yield* Deferred.fail(next.done, error).pipe(Effect.ignore) - }) - - const touch = (event: Event) => { - const next = state.wait - if (!next || !active(event, input.sessionID)) { - return - } - - next.live = true - } - - const complete = Effect.fn("RunStreamTransport.complete")(function* (next: Wait, fallback: boolean) { - if (state.wait !== next || !next.armed || !next.live) { - return - } - - if (!(yield* idle(fallback)) || state.wait !== next) { - return - } - - state.tick = next.tick + 1 - state.wait = undefined - yield* Deferred.succeed(next.done, undefined).pipe(Effect.ignore) - }) - - const mark = Effect.fn("RunStreamTransport.mark")(function* (event: Event) { - if ( - event.type !== "session.status" || - event.properties.sessionID !== input.sessionID || - event.properties.status.type !== "idle" - ) { - return - } - - const next = state.wait - if (!next) { - return - } - - yield* complete(next, true) - }) - - const poll = Effect.fn("RunStreamTransport.poll")(function* (next: Wait, signal: AbortSignal) { - while (state.wait === next && !signal.aborted && !input.footer.isClosed && !closed) { - yield* Effect.sleep("250 millis") - yield* complete(next, false) - } - }) - - const flush = (type: "turn.abort" | "turn.cancel") => { - const commits: StreamCommit[] = [] - flushInterrupted(state.data, commits) - syncFooter(commits) - input.trace?.write(type, { - sessionID: input.sessionID, - }) - } - - const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) { - if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) { - if (replayedParts.has(event.properties.partID)) { - const seen = state.data.text.get(event.properties.partID) ?? "" - if (seen.endsWith(event.properties.delta)) { - return - } - - replayedParts.delete(event.properties.partID) - } - } - - trackBlocker(event) - - const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined - const next = reduceSessionData({ - data: state.data, - event, - sessionID: input.sessionID, - thinking: input.thinking, - limits: input.limits(), - }) - state.data = next.data - const visible = next.commits.at(-1) - if (visible) { - state.wait?.onVisibleOutput?.({ - kind: visible.kind, - text: visible.text, - phase: visible.phase, - messageID: visible.messageID, - partID: visible.partID, - toolState: visible.toolState, - ...(visible.partID && state.data.visible.has(visible.partID) - ? { visible: state.data.visible.get(visible.partID) } - : {}), - }) - } - - if ( - event.type === "message.part.updated" && - event.properties.part.sessionID === input.sessionID && - event.properties.part.type === "tool" && - event.properties.part.tool === "question" && - event.properties.part.state.status === "running" && - state.data.questions.length === 0 - ) { - yield* recoverQuestion(event.properties.part.id).pipe( - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - } - - const changed = reduceSubagentData({ - data: state.subagent, - event, - sessionID: input.sessionID, - thinking: input.thinking, - limits: input.limits(), - }) - if (changed && prev) { - traceTabs(input.trace, prev, listSubagentTabs(state.subagent)) - } - releaseBlocker(event) - - syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined) - - touch(event) - yield* mark(event) - }) - - const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () { - let pending = buffered.splice(0) - while (pending.length > 0) { - const next: Event[] = [] - let changed = false - for (const event of pending) { - if (!tracked(sid(event))) { - next.push(event) - continue - } - - changed = true - yield* applyEvent(event) - } - - const arrived = buffered.splice(0) - if (!changed && arrived.length === 0) { - buffered.push(...next) - return - } - - pending = [...next, ...arrived] - } - }) - - const replayOnResize: (next: SessionResizeReplayInput) => Effect.Effect = Effect.fn( - "RunStreamTransport.replayOnResize", - )(function* (next: SessionResizeReplayInput) { - if (!input.replay || replayDisabled || booting || closed || input.footer.isClosed) { - return false - } - - if (replaying) { - replayPending = next - return false - } - - const finish: () => Effect.Effect = Effect.fnUntraced(function* () { - yield* drainBuffered() - const pending = replayPending - replayPending = undefined - if (!pending || replayDisabled || closed || input.footer.isClosed) { - replaying = false - return - } - - replaying = false - yield* replayOnResize(pending).pipe(Effect.asVoid) - }) - - replayedParts.clear() - replaying = true - input.trace?.write("replay.resize.start", { - sessionID: input.sessionID, - }) - const source = yield* Effect.all([replayMessages(), replayRequests()], { concurrency: "unbounded" }).pipe( - Effect.exit, - ) - if (Exit.isFailure(source)) { - input.trace?.write("replay.resize.abort", { - sessionID: input.sessionID, - phase: "snapshot", - }) - yield* finish() - return false - } - - const [messagesList, [permissions, questions]] = source.value - const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID) - const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID) - const snapshot = yield* Effect.try({ - try: () => { - const history = replaySession({ - messages: messagesList, - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - const activeCommits = replayActiveText(history.data, state.data) - return { - history, - activeCommits, - patch: - history.data.part.size > 0 || history.data.tools.size > 0 - ? { ...history.patch, phase: "running" as const } - : history.patch, - visible: - input.replayLimit !== undefined && messagesList.length > input.replayLimit - ? replaySession({ - messages: messagesList.slice(-input.replayLimit), - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - : history, - } - }, - catch: (error) => error, - }).pipe(Effect.exit) - if (Exit.isFailure(snapshot)) { - input.trace?.write("replay.resize.abort", { - sessionID: input.sessionID, - phase: "snapshot", - }) - yield* finish() - return false - } - - const idle = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit) - if (Exit.isFailure(idle) || closed || input.footer.isClosed) { - yield* finish() - return false - } - - const reset = yield* Effect.promise(() => next.reset()).pipe(Effect.exit) - if (Exit.isFailure(reset)) { - replayDisabled = true - input.trace?.write("replay.resize.disable", { - sessionID: input.sessionID, - phase: "reset", - }) - input.footer.append({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - yield* finish() - return false - } - - state.data = snapshot.value.history.data - for (const request of [...state.data.permissions, ...state.data.questions]) { - seedBlocker(request.id) - } - - for (const commit of replayLocalRows( - messagesList, - [...snapshot.value.visible.commits, ...snapshot.value.activeCommits], - next.localRows(), - )) { - input.trace?.write("ui.commit", commit) - input.footer.append(commit) - } - - syncFooter([], snapshot.value.patch, currentSubagentState()) - const rebuilt = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit) - if (Exit.isFailure(rebuilt)) { - replayDisabled = true - input.trace?.write("replay.resize.disable", { - sessionID: input.sessionID, - phase: "rebuild", - }) - input.footer.append({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - yield* finish() - return false - } - - input.trace?.write("replay.resize.complete", { - sessionID: input.sessionID, - }) - yield* finish() - return true - }) - - const watch = Effect.fn("RunStreamTransport.watch")(() => - Stream.fromAsyncIterable(events.stream, (error) => - error instanceof Error ? error : new Error(String(error)), - ).pipe( - Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted), - Stream.runForEach( - Effect.fn("RunStreamTransport.event")(function* (item: unknown) { - if (input.footer.isClosed) { - abort.abort() - return - } - - if (isMatchingDisposeEvent(item, input.directory)) { - yield* fail(new Error("instance disposed")) - yield* closeScope() - return - } - - const event = globalPayloadEvent(item) - if (!event) { - return - } - - const sessionID = sid(event) - if (booting || replaying) { - if (sessionID) { - input.trace?.write("recv.event", event) - buffered.push(event) - } - return - } - - if (!tracked(sessionID)) { - if (sessionID) { - input.trace?.write("recv.event", event) - buffered.push(event) - } - return - } - - input.trace?.write("recv.event", event) - yield* applyEvent(event) - yield* drainBuffered() - }), - ), - Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))), - Effect.ensuring( - Effect.gen(function* () { - if (!abort.signal.aborted && !state.fault) { - yield* fail(new Error("global event stream closed")) - } - closeStream() - }), - ), - ), - ) - - yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped)) - yield* bootstrap() - - const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) { - if (closed || next.signal?.aborted || input.footer.isClosed) { - return - } - - if (state.fault) { - yield* Effect.fail(state.fault) - return - } - - if (state.wait) { - yield* Effect.fail(new Error("prompt already running")) - return - } - - const item: Wait = { - tick: state.tick, - armed: false, - live: false, - onVisibleOutput: next.onVisibleOutput, - done: yield* Deferred.make(), - } - state.wait = item - state.data.announced = false - - const turn = new AbortController() - const stop = () => { - turn.abort() - } - next.signal?.addEventListener("abort", stop, { once: true }) - abort.signal.addEventListener("abort", stop, { once: true }) - yield* poll(item, turn.signal).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) - - const req = { - sessionID: input.sessionID, - messageID: next.prompt.messageID, - agent: next.agent, - model: next.model, - variant: next.variant, - parts: [ - ...(next.includeFiles ? next.files : []), - { type: "text" as const, text: next.prompt.text }, - ...next.prompt.parts, - ], - } - const command = next.prompt.command - const send = - next.prompt.mode === "shell" - ? Effect.sync(() => { - input.trace?.write("send.shell", { - sessionID: input.sessionID, - command: next.prompt.text, - }) - }).pipe( - Effect.andThen( - resolveShellAgent(next.agent) - .pipe( - Effect.flatMap((agent) => - Effect.promise(() => - input.sdk.session.shell( - { - sessionID: input.sessionID, - agent, - model: next.model, - command: next.prompt.text, - }, - { signal: turn.signal, throwOnError: true }, - ), - ), - ), - ) - .pipe( - Effect.tap(() => - Effect.sync(() => { - input.trace?.write("send.shell.ok", { - sessionID: input.sessionID, - }) - item.armed = true - item.live = true - }), - ), - Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)), - Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)), - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ), - ), - ) - : command - ? Effect.sync(() => { - input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name }) - }).pipe( - Effect.andThen( - Effect.promise(() => - input.sdk.session.command( - { - sessionID: input.sessionID, - messageID: next.prompt.messageID, - agent: next.agent, - model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined, - variant: next.variant, - command: command.name, - arguments: command.arguments, - parts: [ - ...(next.includeFiles ? next.files : []), - ...next.prompt.parts.filter( - (item): item is Extract => item.type === "file", - ), - ], - }, - { signal: turn.signal }, - ), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - input.trace?.write("send.command.ok", { - sessionID: input.sessionID, - command: command.name, - }) - item.armed = true - item.live = true - }), - ), - Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)), - Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)), - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ), - ), - ) - : Effect.sync(() => { - input.trace?.write("send.prompt", req) - }).pipe( - Effect.andThen( - Effect.promise(() => - input.sdk.session.promptAsync(req, { - signal: turn.signal, - }), - ), - ), - Effect.tap(() => - Effect.sync(() => { - input.trace?.write("send.prompt.ok", { - sessionID: input.sessionID, - }) - item.armed = true - }), - ), - ) - - yield* send.pipe( - Effect.flatMap(() => { - if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed || closed) { - if (state.wait === item) { - state.wait = undefined - } - flush("turn.abort") - return Effect.void - } - - if (!input.footer.isClosed && !state.data.announced) { - input.trace?.write("ui.patch", { - phase: "running", - status: "waiting for assistant", - }) - input.footer.event({ - type: "turn.wait", - }) - } - - if (state.tick > item.tick) { - if (state.wait === item) { - state.wait = undefined - } - return Effect.void - } - - return waitTurn(item.done, turn.signal).pipe( - Effect.flatMap((status) => - Effect.sync(() => { - if (state.wait === item) { - state.wait = undefined - } - - if (status === "abort") { - flush("turn.abort") - } - }), - ), - ) - }), - Effect.catch((error) => { - if (state.wait === item) { - state.wait = undefined - } - - const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed || closed - if (canceled) { - flush("turn.cancel") - return Effect.void - } - - if (error === state.fault) { - return Effect.fail(error) - } - - input.trace?.write("send.prompt.error", { - sessionID: input.sessionID, - error: formatUnknownError(error), - }) - return Effect.fail(error) - }), - Effect.ensuring( - Effect.sync(() => { - input.trace?.write("turn.end", { - sessionID: input.sessionID, - }) - next.signal?.removeEventListener("abort", stop) - abort.signal.removeEventListener("abort", stop) - }), - ), - ) - return - }) - - const selectSubagent = Effect.fn("RunStreamTransport.selectSubagent")((sessionID: string | undefined) => - Effect.sync(() => { - if (closed) { - return - } - - const next = sessionID && state.subagent.tabs.has(sessionID) ? sessionID : undefined - if (state.selectedSubagent === next) { - return - } - - state.selectedSubagent = next - syncFooter([], undefined, currentSubagentState()) - }), - ) - - const close = Effect.fn("RunStreamTransport.close")(function* () { - yield* closeScope() - }) - - return Service.of({ - runPromptTurn, - selectSubagent, - replayOnResize, - close, - }) - }), - ), - ) -} - -// Opens an SDK event subscription and returns a SessionTransport. -// -// The background `watch` loop consumes every SDK event, runs it through the -// reducer, and writes output to the footer. When a session.status idle -// event arrives, it resolves the current turn's Wait so runPromptTurn() -// can return. -// -// The transport is single-turn: only one runPromptTurn() call can be active -// at a time. The prompt queue enforces this from above. -export async function createSessionTransport(input: StreamInput): Promise { - const runtime = makeRuntime(Service, createLayer(input)) - await runtime.runPromise(() => Effect.void) - - return { - runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)), - selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)), - replayOnResize: (next) => runtime.runPromise((svc) => svc.replayOnResize(next)), - close: () => runtime.runPromise((svc) => svc.close()), - } -} diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts deleted file mode 100644 index 172741d3b3..0000000000 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ /dev/null @@ -1,876 +0,0 @@ -import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" -import * as Locale from "@/util/locale" -import { - bootstrapSessionData, - createSessionData, - formatError, - reduceSessionData, - type SessionData, -} from "./session-data" -import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" - -export const SUBAGENT_BOOTSTRAP_LIMIT = 200 -export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80 - -const SUBAGENT_COMMIT_LIMIT = 80 -const SUBAGENT_CALL_LIMIT = 32 -const SUBAGENT_ROLE_LIMIT = 32 -const SUBAGENT_ERROR_LIMIT = 16 -const SUBAGENT_ECHO_LIMIT = 8 - -type SessionMessage = { - parts: Part[] -} - -type BootstrapChildMessage = SessionMessage & { - info: Message -} - -type Frame = { - key: string - commit: StreamCommit -} - -type DetailState = { - sessionID: string - data: SessionData - frames: Frame[] -} - -export type SubagentData = { - tabs: Map - details: Map -} - -export type BootstrapSubagentInput = { - data: SubagentData - messages: SessionMessage[] - children: Array<{ id: string; title?: string }> - permissions: PermissionRequest[] - questions: QuestionRequest[] -} - -function createDetail(sessionID: string): DetailState { - return { - sessionID, - data: createSessionData({ - includeUserText: true, - }), - frames: [], - } -} - -function ensureDetail(data: SubagentData, sessionID: string) { - const current = data.details.get(sessionID) - if (current) { - return current - } - - const next = createDetail(sessionID) - data.details.set(sessionID, next) - return next -} - -export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) { - if (!a || !b) { - return false - } - - return ( - a.sessionID === b.sessionID && - a.partID === b.partID && - a.callID === b.callID && - a.label === b.label && - a.description === b.description && - a.status === b.status && - a.background === b.background && - a.title === b.title && - a.toolCalls === b.toolCalls && - a.lastUpdatedAt === b.lastUpdatedAt - ) -} - -function sameQueue(left: T[], right: T[]) { - return ( - left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index]) - ) -} - -function queueSnapshot(data: SessionData) { - return { - permissions: data.permissions.slice(), - questions: data.questions.slice(), - } -} - -function queueChanged(data: SessionData, before: ReturnType) { - return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions) -} - -function sameCommit(left: StreamCommit, right: StreamCommit) { - return ( - left.kind === right.kind && - left.text === right.text && - left.phase === right.phase && - left.source === right.source && - left.messageID === right.messageID && - left.partID === right.partID && - left.tool === right.tool && - left.interrupted === right.interrupted && - left.toolState === right.toolState && - left.toolError === right.toolError - ) -} - -function text(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined - } - - const next = value.trim() - return next || undefined -} - -function num(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - - return undefined -} - -function inputLabel(input: Record): string | undefined { - const description = text(input.description) - if (description) { - return description - } - - const command = text(input.command) - if (command) { - return command - } - - const filePath = text(input.filePath) ?? text(input.filepath) - if (filePath) { - return filePath - } - - const pattern = text(input.pattern) - if (pattern) { - return pattern - } - - const query = text(input.query) - if (query) { - return query - } - - const url = text(input.url) - if (url) { - return url - } - - const path = text(input.path) - if (path) { - return path - } - - const prompt = text(input.prompt) - if (prompt) { - return prompt - } - - return undefined -} - -function stateTitle(part: ToolPart) { - return text("title" in part.state ? part.state.title : undefined) -} - -function callKey(messageID: string | undefined, callID: string | undefined): string | undefined { - if (!messageID || !callID) { - return undefined - } - - return `${messageID}:${callID}` -} - -function compactToolState(part: ToolPart): ToolPart["state"] { - if (part.state.status === "pending") { - return { - status: "pending", - input: part.state.input, - raw: part.state.raw, - } - } - - if (part.state.status === "running") { - return { - status: "running", - input: part.state.input, - time: part.state.time, - ...(part.state.metadata ? { metadata: part.state.metadata } : {}), - ...(part.state.title ? { title: part.state.title } : {}), - } - } - - if (part.state.status === "completed") { - return { - status: "completed", - input: part.state.input, - output: part.state.output, - title: part.state.title, - metadata: part.state.metadata, - time: part.state.time, - } - } - - return { - status: "error", - input: part.state.input, - error: part.state.error, - time: part.state.time, - ...(part.state.metadata ? { metadata: part.state.metadata } : {}), - } -} - -function recent(input: Iterable, limit: number) { - const list = [...input] - return list.slice(Math.max(0, list.length - limit)) -} - -function copyMap(source: Map, keep: Set) { - const out = new Map() - for (const [key, value] of source) { - if (!keep.has(key)) { - continue - } - - out.set(key, value) - } - return out -} - -function compactToolPart(part: ToolPart): ToolPart { - return { - id: part.id, - type: "tool", - sessionID: part.sessionID, - messageID: part.messageID, - callID: part.callID, - tool: part.tool, - state: compactToolState(part), - ...(part.metadata ? { metadata: part.metadata } : {}), - } -} - -function compactCommit(commit: StreamCommit): StreamCommit { - if (!commit.part) { - return commit - } - - return { - ...commit, - part: compactToolPart(commit.part), - } -} - -function stateUpdatedAt(part: ToolPart) { - if (!("time" in part.state)) { - return Date.now() - } - - const time = part.state.time - if (!("end" in time)) { - return time.start ?? Date.now() - } - - return time.end ?? time.start ?? Date.now() -} - -function metadata(part: ToolPart, key: string) { - return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key] -} - -function taskStatus(part: ToolPart): FooterSubagentTab["status"] { - if (part.state.status === "completed") { - return "completed" - } - - if (part.state.status === "error") { - if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") { - return "cancelled" - } - - return "error" - } - - return "running" -} - -function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab { - const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general") - const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? "" - - return { - sessionID, - partID: part.id, - callID: part.callID, - label, - description, - status: taskStatus(part), - background: metadata(part, "background") === true, - title: stateTitle(part), - toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")), - lastUpdatedAt: stateUpdatedAt(part), - } -} - -function taskSessionID(part: ToolPart) { - return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID")) -} - -function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set) { - if (part.tool !== "task") { - return false - } - - const sessionID = taskSessionID(part) - if (!sessionID) { - return false - } - - if (children && children.size > 0 && !children.has(sessionID)) { - return false - } - - const next = taskTab(part, sessionID) - if (sameSubagentTab(data.tabs.get(sessionID), next)) { - ensureDetail(data, sessionID) - return false - } - - data.tabs.set(sessionID, next) - ensureDetail(data, sessionID) - return true -} - -function frameKey(commit: StreamCommit) { - if (commit.partID) { - return `${commit.kind}:${commit.partID}:${commit.phase}` - } - - if (commit.messageID) { - return `${commit.kind}:${commit.messageID}:${commit.phase}` - } - - return `${commit.kind}:${commit.phase}:${commit.text}` -} - -function limitFrames(detail: DetailState) { - if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) { - return - } - - detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT) -} - -function mergeLiveCommit(current: StreamCommit, next: StreamCommit) { - if (current.phase !== "progress" || next.phase !== "progress") { - if (sameCommit(current, next)) { - return current - } - - return next - } - - const merged = { - ...current, - ...next, - text: current.text + next.text, - } - - if (sameCommit(current, merged)) { - return current - } - - return merged -} - -function appendCommits(detail: DetailState, commits: StreamCommit[]) { - let changed = false - - for (const commit of commits.map(compactCommit)) { - const key = frameKey(commit) - const index = detail.frames.findIndex((item) => item.key === key) - if (index === -1) { - detail.frames.push({ - key, - commit, - }) - changed = true - continue - } - - const next = mergeLiveCommit(detail.frames[index].commit, commit) - if (sameCommit(detail.frames[index].commit, next)) { - continue - } - - detail.frames[index] = { - key, - commit: next, - } - changed = true - } - - if (changed) { - limitFrames(detail) - } - - return changed -} - -function ensureBlockerTab( - data: SubagentData, - sessionID: string, - title: string | undefined, - kind: "permission" | "question", -) { - const current = data.tabs.get(sessionID) - if (current) { - ensureDetail(data, sessionID) - if (current.status !== "running") { - return false - } - - const next = { - ...current, - description: kind === "permission" ? "Pending permission" : "Pending question", - status: "running" as const, - title: current.title ?? title, - lastUpdatedAt: Date.now(), - } - if (sameSubagentTab(current, next)) { - return false - } - - data.tabs.set(sessionID, next) - return true - } - - data.tabs.set(sessionID, { - sessionID, - partID: `bootstrap:${sessionID}`, - callID: `bootstrap:${sessionID}`, - label: text(title) ?? Locale.titlecase(kind), - description: kind === "permission" ? "Pending permission" : "Pending question", - status: "running", - lastUpdatedAt: Date.now(), - }) - ensureDetail(data, sessionID) - return true -} - -function isAbortedAssistantMessage(info: Message) { - return info.role === "assistant" && info.error?.name === "MessageAbortedError" -} - -function cancelSubagentTab(data: SubagentData, sessionID: string) { - const current = data.tabs.get(sessionID) - if (!current || current.status !== "running") { - return false - } - - const next = { - ...current, - status: "cancelled" as const, - lastUpdatedAt: Date.now(), - } - if (sameSubagentTab(current, next)) { - return false - } - - data.tabs.set(sessionID, next) - return true -} - -function compactCallMap(detail: DetailState) { - const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT)) - - for (const request of detail.data.permissions) { - const key = callKey(request.tool?.messageID, request.tool?.callID) - if (key) { - keep.add(key) - } - } - - for (const item of detail.frames) { - const key = callKey(item.commit.part?.messageID, item.commit.part?.callID) - if (key) { - keep.add(key) - } - } - - return copyMap(detail.data.call, keep) -} - -function compactEchoMap(data: SessionData, messageIDs: Set) { - const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)]) - return copyMap(data.echo, keys) -} - -function compactIDs(detail: DetailState) { - return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT)) -} - -function compactDetail(detail: DetailState) { - const next = createSessionData({ - includeUserText: true, - }) - const activePartIDs = new Set(detail.data.part.keys()) - const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : []))) - const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools]) - const messageIDs = new Set([ - ...[...activePartIDs] - .map((partID) => detail.data.msg.get(partID)) - .filter((item): item is string => typeof item === "string"), - ...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT), - ]) - - next.announced = detail.data.announced - next.permissions = detail.data.permissions - next.questions = detail.data.questions - next.ids = compactIDs(detail) - next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item))) - next.call = compactCallMap(detail) - next.role = copyMap(detail.data.role, messageIDs) - next.msg = copyMap(detail.data.msg, activePartIDs) - next.part = copyMap(detail.data.part, activePartIDs) - next.text = copyMap(detail.data.text, activePartIDs) - next.sent = copyMap(detail.data.sent, activePartIDs) - next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item))) - next.echo = compactEchoMap(detail.data, messageIDs) - detail.data = next -} - -function applyChildEvent(input: { - detail: DetailState - event: Event - thinking: boolean - limits: Record -}) { - const before = queueSnapshot(input.detail.data) - const out = reduceSessionData({ - data: input.detail.data, - event: input.event, - sessionID: input.detail.sessionID, - thinking: input.thinking, - limits: input.limits, - }) - const changed = appendCommits(input.detail, out.commits) - compactDetail(input.detail) - - return changed || queueChanged(input.detail.data, before) -} - -function bootstrapChildEvent(input: { - detail: DetailState - event: Event - thinking: boolean - limits: Record -}) { - const out = reduceSessionData({ - data: input.detail.data, - event: input.event, - sessionID: input.detail.sessionID, - thinking: input.thinking, - limits: input.limits, - }) - - return appendCommits(input.detail, out.commits) -} - -function bootstrapChildMessages(input: { - detail: DetailState - messages: BootstrapChildMessage[] - thinking: boolean - limits: Record -}) { - let changed = false - - for (const message of input.messages) { - changed = - bootstrapChildEvent({ - detail: input.detail, - event: { - id: `bootstrap:message:${message.info.id}`, - type: "message.updated", - properties: { - sessionID: input.detail.sessionID, - info: message.info, - }, - }, - thinking: input.thinking, - limits: input.limits, - }) || changed - - for (const part of message.parts) { - changed = - bootstrapChildEvent({ - detail: input.detail, - event: { - id: `bootstrap:part:${part.id}`, - type: "message.part.updated", - properties: { - sessionID: input.detail.sessionID, - part, - time: 0, - }, - }, - thinking: input.thinking, - limits: input.limits, - }) || changed - } - } - - compactDetail(input.detail) - return changed -} - -function knownSession(data: SubagentData, sessionID: string) { - return data.tabs.has(sessionID) -} - -export function listSubagentPermissions(data: SubagentData) { - return [...data.details.values()].flatMap((detail) => detail.data.permissions) -} - -export function listSubagentQuestions(data: SubagentData) { - return [...data.details.values()].flatMap((detail) => detail.data.questions) -} - -export function createSubagentData(): SubagentData { - return { - tabs: new Map(), - details: new Map(), - } -} - -function snapshotDetail(detail: DetailState) { - return { - sessionID: detail.sessionID, - commits: detail.frames.map((item) => item.commit), - } -} - -export function listSubagentTabs(data: SubagentData) { - return [...data.tabs.values()].sort((a, b) => { - const active = Number(b.status === "running") - Number(a.status === "running") - if (active !== 0) { - return active - } - - return b.lastUpdatedAt - a.lastUpdatedAt - }) -} - -function snapshotQueues(data: SubagentData) { - return { - permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)), - questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)), - } -} - -function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState { - return { - tabs: listSubagentTabs(data), - details, - ...snapshotQueues(data), - } -} - -export function snapshotSubagentData(data: SubagentData): FooterSubagentState { - return snapshotState( - data, - Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])), - ) -} - -export function snapshotSelectedSubagentData( - data: SubagentData, - selectedSessionID: string | undefined, -): FooterSubagentState { - const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined - - return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {}) -} - -export function bootstrapSubagentData(input: BootstrapSubagentInput) { - const child = new Map(input.children.map((item) => [item.id, item])) - const children = new Set(child.keys()) - let changed = false - - for (const message of input.messages) { - for (const part of message.parts) { - if (part.type !== "tool") { - continue - } - - changed = syncTaskTab(input.data, part, children) || changed - } - } - - for (const item of input.permissions) { - if (!children.has(item.sessionID)) { - continue - } - - changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed - } - - for (const item of input.questions) { - if (!children.has(item.sessionID)) { - continue - } - - changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed - } - - for (const sessionID of input.data.tabs.keys()) { - const detail = ensureDetail(input.data, sessionID) - const before = queueSnapshot(detail.data) - - bootstrapSessionData({ - data: detail.data, - messages: [], - permissions: input.permissions - .filter((item) => item.sessionID === sessionID) - .sort((a, b) => a.id.localeCompare(b.id)), - questions: input.questions - .filter((item) => item.sessionID === sessionID) - .sort((a, b) => a.id.localeCompare(b.id)), - }) - compactDetail(detail) - - changed = queueChanged(detail.data, before) || changed - } - - return changed -} - -export function bootstrapSubagentCalls(input: { - data: SubagentData - sessionID: string - messages: BootstrapChildMessage[] - thinking: boolean - limits: Record -}) { - if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) { - return false - } - - const detail = ensureDetail(input.data, input.sessionID) - const before = queueSnapshot(detail.data) - const beforeCallCount = detail.data.call.size - bootstrapSessionData({ - data: detail.data, - messages: input.messages, - permissions: detail.data.permissions, - questions: detail.data.questions, - }) - const changed = bootstrapChildMessages({ - detail, - messages: input.messages, - thinking: input.thinking, - limits: input.limits, - }) - - return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before) -} - -export function reduceSubagentData(input: { - data: SubagentData - event: Event - sessionID: string - thinking: boolean - limits: Record -}) { - const event = input.event - - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID === input.sessionID) { - if (part.type !== "tool") { - return false - } - - return syncTaskTab(input.data, part) - } - } - - const sessionID = - event.type === "message.updated" || - event.type === "message.part.delta" || - event.type === "permission.asked" || - event.type === "permission.replied" || - event.type === "question.asked" || - event.type === "question.replied" || - event.type === "question.rejected" || - event.type === "session.error" || - event.type === "session.status" - ? event.properties.sessionID - : event.type === "message.part.updated" - ? event.properties.part.sessionID - : undefined - - if (!sessionID || !knownSession(input.data, sessionID)) { - return false - } - - const detail = ensureDetail(input.data, sessionID) - const cancelled = - event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info) - ? cancelSubagentTab(input.data, sessionID) - : false - if (event.type === "session.status") { - if (event.properties.status.type !== "retry") { - return cancelled - } - - return ( - appendCommits(detail, [ - { - kind: "error", - text: event.properties.status.message, - phase: "start", - source: "system", - messageID: `retry:${event.properties.status.attempt}`, - }, - ]) || cancelled - ) - } - - if (event.type === "session.error" && event.properties.error) { - return ( - appendCommits(detail, [ - { - kind: "error", - text: formatError(event.properties.error), - phase: "start", - source: "system", - messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`, - }, - ]) || cancelled - ) - } - - return ( - applyChildEvent({ - detail, - event, - thinking: input.thinking, - limits: input.limits, - }) || cancelled - ) -} diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts index aadac67afc..aa63a8ea0a 100644 --- a/packages/opencode/src/cli/cmd/run/turn-summary.ts +++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts @@ -1,6 +1,4 @@ -import * as Locale from "@/util/locale" -import type { SessionMessages } from "./session.shared" -import type { RunProvider, StreamCommit } from "./types" +import type { StreamCommit } from "./types" export function turnSummaryCommit(input: { agent: string @@ -21,27 +19,3 @@ export function turnSummaryCommit(input: { messageID: input.messageID, } } - -export function messageTurnSummaryCommit( - message: SessionMessages[number], - providers?: RunProvider[], -): StreamCommit | undefined { - const info = message.info - if (info.role !== "assistant") { - return - } - - const completed = info.time.completed - if (typeof completed !== "number" || completed <= info.time.created) { - return - } - - const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name - - return turnSummaryCommit({ - agent: Locale.titlecase(info.agent), - model: model ?? info.modelID, - duration: Locale.duration(completed - info.time.created), - messageID: info.id, - }) -} diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a914922e48..27372596f1 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -1,4 +1,4 @@ -// Shared type vocabulary for the direct interactive mode (`opencode --mini`). +// Shared type vocabulary for the direct interactive mode (`opencode mini`). // // Direct mode uses a split-footer terminal layout: immutable scrollback for the // session transcript, and a mutable footer for prompt input, status, and @@ -26,9 +26,63 @@ type PromptInput = Parameters[0] export type RunPromptPart = NonNullable[number] -export type RunCommand = NonNullable>["data"]>[number] +export type RunCommand = { + name: string + description?: string + source?: string + template?: string + hints?: unknown[] + agent?: string + model?: { + [key: string]: unknown + } + subtask?: boolean +} -export type RunProvider = NonNullable>["data"]>["all"][number] +export type RunProviderModel = { + id: string + providerID: string + api?: { + [key: string]: unknown + } + name?: string + capabilities?: { + [key: string]: unknown + } + cost?: { + input: number + output?: number + cache?: { + read: number + write: number + } + } + limit?: { + context: number + input?: number + output?: number + } + status?: string + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + release_date?: string + variants?: Record +} + +export type RunProvider = { + id: string + name: string + source?: string + env?: string[] + options?: { + [key: string]: unknown + } + models: Record +} export type RunPrompt = { messageID?: string @@ -48,11 +102,16 @@ export type FooterQueuedPrompt = { prompt: RunPrompt } -export type RunAgent = NonNullable>["data"]>[number] +export type RunAgent = { + name: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean +} -type RunResourceMap = NonNullable>["data"]> - -export type RunResource = RunResourceMap[string] +export type RunReference = NonNullable< + Awaited>["data"] +>["data"][number] export type RunInput = { sdk: OpencodeClient @@ -224,7 +283,7 @@ export type FooterEvent = | { type: "catalog" agents: RunAgent[] - resources: RunResource[] + references: RunReference[] commands?: RunCommand[] } | { diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/opencode/src/cli/cmd/run/variant.shared.ts index e685ceb028..fa10af0055 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/opencode/src/cli/cmd/run/variant.shared.ts @@ -10,6 +10,8 @@ import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeRuntime } from "@/effect/run-service" import { Global } from "@opencode-ai/core/global" import { isRecord } from "@/util/record" @@ -136,69 +138,69 @@ function state(value: unknown): ModelState { } } -function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) { - return Layer.fresh( - Layer.effect( - Service, - Effect.gen(function* () { - const file = yield* FSUtil.Service +const layer = Layer.fresh( + Layer.effect( + Service, + Effect.gen(function* () { + const file = yield* FSUtil.Service - const read = Effect.fn("RunVariant.read")(function* () { - return yield* file.readJson(MODEL_FILE).pipe( - Effect.map(state), - Effect.catchCause(() => Effect.succeed(state(undefined))), - ) - }) + const read = Effect.fn("RunVariant.read")(function* () { + return yield* file.readJson(MODEL_FILE).pipe( + Effect.map(state), + Effect.catchCause(() => Effect.succeed(state(undefined))), + ) + }) - const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) { - if (!model) { - return undefined - } + const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) { + if (!model) { + return undefined + } - return (yield* read()).variant?.[variantKey(model)] - }) + return (yield* read()).variant?.[variantKey(model)] + }) - const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( - model: RunInput["model"], - variant: string | undefined, - ) { - if (!model) { - return - } + const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( + model: RunInput["model"], + variant: string | undefined, + ) { + if (!model) { + return + } - const current = yield* read() - const next = { - ...current.variant, - } - const key = variantKey(model) - if (variant) { - next[key] = variant - } + const current = yield* read() + const next = { + ...current.variant, + } + const key = variantKey(model) + if (variant) { + next[key] = variant + } - if (!variant) { - delete next[key] - } + if (!variant) { + delete next[key] + } - yield* file - .writeJson(MODEL_FILE, { - ...current, - variant: next, - }) - .pipe(Effect.orElseSucceed(() => undefined)) - }) + yield* file + .writeJson(MODEL_FILE, { + ...current, + variant: next, + }) + .pipe(Effect.orElseSucceed(() => undefined)) + }) - return Service.of({ - resolveSavedVariant, - saveVariant, - }) - }), - ).pipe(Layer.provide(fs)), - ) -} + return Service.of({ + resolveSavedVariant, + saveVariant, + }) + }), + ), +) + +const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] }) /** @internal Exported for testing. */ -export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime { - const runtime = makeRuntime(Service, createLayer(fs)) +export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime { + const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements)) return { resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined), saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index feedd51d8e..54a0079813 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -89,73 +89,8 @@ export const TuiThreadCommand = cmd({ type: "boolean", hidden: true, default: false, - }) - .option("mini", { - type: "boolean", - describe: "start the minimal interactive interface", - default: false, - }) - .option("replay", { - type: "boolean", - hidden: true, - }) - .option("no-replay", { - type: "boolean", - describe: "disable mini session history replay on resume and after resize", - }) - .option("replay-limit", { - type: "number", - describe: "cap visible mini replay to the newest N messages", - }) - .option("demo", { - type: "boolean", - hidden: true, }), handler: async (args) => { - if (args.replay === true) { - UI.error("--replay is not supported; replay is enabled by default") - process.exitCode = 1 - return - } - const noReplay = args.replay === false || args.noReplay === true - - if (args.mini) { - const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) => - process.argv.some((arg) => arg === option || arg.startsWith(option + "=")), - ) - if (network) { - UI.error(`${network} cannot be used with --mini`) - process.exitCode = 1 - return - } - - const { runMini } = await import("./run") - await runMini({ - directory: resolveThreadDirectory(args.project), - continue: args.continue, - session: args.session, - fork: args.fork, - model: args.model, - agent: args.agent, - prompt: args.prompt, - replay: noReplay ? false : undefined, - replayLimit: args.replayLimit, - demo: args.demo, - }) - return - } - - const unsupported = [ - ["--no-replay", noReplay], - ["--replay-limit", args.replayLimit !== undefined], - ["--demo", args.demo !== undefined], - ].find((entry) => entry[1])?.[0] - if (unsupported) { - UI.error(`${unsupported} requires --mini`) - process.exitCode = 1 - return - } - const unguard = win32InstallCtrlCGuard() try { const { TuiConfig } = await import("@/config/tui") diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a3..f17904bd49 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -19,6 +19,7 @@ import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" +import { MiniCommand } from "./cli/cmd/mini" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" @@ -80,6 +81,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) + .command(MiniCommand) .command(TuiThreadCommand) .command(AttachCommand) .command(RunCommand) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 2a7266c511..692c3d4591 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -34,6 +34,8 @@ type OpenApiSchema = { additionalProperties?: OpenApiSchema | boolean allOf?: OpenApiSchema[] anyOf?: OpenApiSchema[] + contentMediaType?: string + contentSchema?: OpenApiSchema description?: string enum?: Array items?: OpenApiSchema @@ -97,6 +99,7 @@ function matchLegacyOpenApi(input: Record) { } normalizeComponentNames(spec) collapseDuplicateComponents(spec) + normalizeV2EventSchemas(spec) applyLegacySchemaOverrides(spec) normalizeComponentDescriptions(spec) addLegacyErrorSchemas(spec) @@ -229,6 +232,16 @@ function collapseDuplicateComponents(spec: OpenApiSpec) { } } +function normalizeV2EventSchemas(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas?.V2Event1?.anyOf || schemas.V2Event?.type !== "string") return + schemas.V2EventStream = schemas.V2Event + rewriteRefs(spec, "V2Event", "V2EventStream") + schemas.V2Event = schemas.V2Event1 + delete schemas.V2Event1 + rewriteRefs(spec, "V2Event1", "V2Event") +} + function normalizeComponentNames(spec: OpenApiSpec) { const schemas = spec.components?.schemas if (!schemas) return diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index db2ae41db4..1d4f31ae93 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -66,6 +66,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -297,8 +298,11 @@ export function createRoutes( Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(PtyEnvironment.layer), + // PluginRuntime.providerNode shares this build so plugin tools (subagent, + // shell jobs) capture the same SessionV2/Job instances the handlers use. + // Without it the plugin runtime cell stays empty and subagents cannot spawn. Layer.provide( - AppNodeBuilderV1.build(SessionV2.node, [ + AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, PluginRuntime.providerNode]), [ [LocationServiceMap.node, locationServiceMapV2], [SessionExecution.node, SessionExecutionLocal.node], ]), diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts index 95461f301b..b100ba9d10 100644 --- a/packages/opencode/src/temporary.ts +++ b/packages/opencode/src/temporary.ts @@ -1,4 +1,5 @@ import yargs from "yargs" +import { MiniCommand } from "./cli/cmd/mini" import { TuiThreadCommand } from "./cli/cmd/tui" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { hideBin } from "yargs/helpers" @@ -27,5 +28,6 @@ const cli = yargs(hideBin(process.argv)) if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel }) + .command(MiniCommand) .command(TuiThreadCommand) .parse() diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad2338..bd5dc88e7e 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,6 +41,34 @@ Options: --pure run without external plugins [boolean]" `; +exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = ` +"opencode mini + +start the minimal interactive interface + +Commands: + opencode mini [project] start the minimal interactive interface [default] + opencode mini attach attach to a running opencode server with the minimal interface + +Positionals: + project path to start opencode in [string] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --prompt prompt to use [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --no-replay disable session history replay on resume and after resize [boolean] + --replay-limit cap visible replay to the newest N messages [number]" +`; + exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = ` "opencode attach @@ -50,21 +78,17 @@ Positionals: url http://localhost:4096 [string] [required] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory to run in [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') - [string] - --mini start the minimal interactive interface [boolean] [default: false] - --no-replay disable mini session history replay on resume and after resize [boolean] - --replay-limit cap visible mini replay to the newest N messages [number]" + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory to run in [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` @@ -85,7 +109,6 @@ Options: -c, --continue continue the last session [boolean] -s, --session session id to continue [string] --fork fork the session before continuing (requires --continue or --session) [boolean] - --share share the session [boolean] -m, --model model to use in the format of provider/model [string] --agent agent to use [string] --format format: default (formatted) or json (raw JSON events) @@ -403,6 +426,31 @@ Options: --format Output format [string] [choices: "json", "tsv"] [default: "tsv"]" `; +exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = ` +"opencode mini attach + +attach to a running opencode server with the minimal interface + +Positionals: + url http://localhost:4096 [string] [required] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory on the remote server [string] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') + [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --no-replay disable session history replay on resume and after resize [boolean] + --replay-limit cap visible replay to the newest N messages [number]" +`; + exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = ` "opencode mcp list diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 3a14d0d7ec..fc3dabeef3 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -13,16 +13,27 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { fileURLToPath } from "node:url" import { cliIt } from "../../lib/cli-process" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" +const PACKAGE_ROOT_PATTERN = new RegExp( + fileURLToPath(new URL("../../..", import.meta.url)) + .replace(/[/\\]$/, "") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + "g", +) + // Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific // rules: // // 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to ``. // `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows. // -// 2. yargs wraps the `[string] [default: "..."]` clause based on the +// 2. Some command defaults use the package cwd when the harness spawns the +// CLI. Collapse that path too so snapshots do not depend on checkout path. +// +// 3. yargs wraps the `[string] [default: "..."]` clause based on the // pre-normalized default's character length, so different random home // path widths produce different leading-whitespace counts (or even // line-wraps onto a fresh line on Windows). `\s+` matches both forms. @@ -33,6 +44,7 @@ function normalize(text: string): string { // (the harness now uses FileSystem.makeTempDirectoryScoped under the // hood). A `[a-z0-9]+` regex would leave uppercase chars trailing. [new RegExp(`${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), ""], + [PACKAGE_ROOT_PATTERN, ""], [/\s+\[string\] \[default: ""\]/g, ' [string] [default: ""]'], ], }) @@ -45,6 +57,7 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", + "mini", "attach", "run", "debug", @@ -69,6 +82,7 @@ const TOP_LEVEL = [ // distinct argv shape, not every leaf. Add new entries when a subcommand // gains user-visible flags that we want to lock in. const SUBCOMMANDS = [ + ["mini", "attach"], ["mcp", "list"], ["mcp", "add"], ["mcp", "auth"], @@ -101,7 +115,8 @@ describe("opencode CLI help-text snapshots", () => { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) expect(topLevel.stderr.endsWith("\n")).toBe(true) - expect(topLevel.stderr).toContain("--mini") + expect(topLevel.stderr).toContain("opencode mini") + expect(topLevel.stderr).not.toContain("--mini") expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--variant") expect(topLevel.stderr).not.toContain("--demo") diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts new file mode 100644 index 0000000000..bdf57ef7d1 --- /dev/null +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared" + +afterEach(() => { + mock.restore() +}) + +describe("run catalog shared", () => { + test("loads visible project references from the current reference catalog", async () => { + const client = new OpencodeClient() + const list = spyOn(client.v2.reference, "list").mockImplementation( + () => + Promise.resolve({ + data: { + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: [ + { + name: "effect", + path: "/repos/effect", + description: "Effect v4 sources", + source: { type: "local", path: "/repos/effect" }, + }, + { + name: "secret", + path: "/repos/secret", + hidden: true, + source: { type: "local", path: "/repos/secret" }, + }, + ], + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) as never, + ) + + const references = await loadRunReferences(client, "/tmp") + + expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }]) + }) + + test("merges current providers and models into the footer catalog shape", () => { + const providers = runProviders( + [ + { + id: "openai", + name: "OpenAI", + api: { type: "native", settings: {} }, + request: { settings: {}, headers: {}, body: {} }, + }, + ], + [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { id: "openai", type: "native", settings: {} }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + request: { + settings: {}, + headers: {}, + body: {}, + }, + variants: [ + { + id: "high", + settings: {}, + headers: {}, + body: {}, + }, + ], + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], + status: "active", + enabled: true, + limit: { + context: 128000, + output: 8192, + }, + }, + ], + ) + + expect(providers).toEqual([ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5": { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + capabilities: expect.objectContaining({ tools: true }), + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + variants: { + high: {}, + }, + }, + }, + }, + ]) + }) +}) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 2e9fd8ef17..83042040f2 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -187,7 +187,7 @@ async function renderFooter( directory="/tmp" findFiles={async () => []} agents={() => []} - resources={() => []} + references={() => []} commands={() => input.commands ?? []} providers={() => input.providers} currentModel={() => input.currentModel} @@ -934,7 +934,7 @@ test("direct footer shows editable prompts and additional queued work while runn directory="/tmp" findFiles={async () => []} agents={() => []} - resources={() => []} + references={() => []} commands={() => []} providers={() => undefined} currentModel={() => ({ diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e272..30ae27b68d 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -2,11 +2,12 @@ // These exercise the real CLI binary against a TestLLMServer running in the // same process. See `test/lib/cli-process.ts` for the harness — each test uses // `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with -// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline. +// an isolated test provider config under the fixture's temp home. import { describe, expect } from "bun:test" import { Effect } from "effect" import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" describe("opencode run (non-interactive subprocess)", () => { // Happy path: prompt completes, output reaches stdout, process exits 0. @@ -28,7 +29,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text(" before tool ").tool("bash", { + reply().text(" before tool ").tool("shell", { command: "printf tool-output", description: "Print deterministic output", }), @@ -89,7 +90,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text("partial response").tool("bash", { + reply().text("partial response").tool("shell", { command: "printf tool", description: "Print deterministic output", }), @@ -168,7 +169,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().reason("reasoning").text("before").tool("bash", { + reply().reason("reasoning").text("before").tool("shell", { command: "printf tool", description: "Print deterministic output", }), @@ -198,7 +199,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(events.find((event) => event.type === "tool_use")?.part).toEqual( expect.objectContaining({ type: "tool", - tool: "bash", + tool: "shell", state: expect.objectContaining({ status: "completed" }), }), ) @@ -217,7 +218,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text("partial json").tool("bash", { + reply().text("partial json").tool("shell", { command: "printf tool", description: "Print deterministic output", }), @@ -227,16 +228,9 @@ describe("opencode run (non-interactive subprocess)", () => { const events = opencode.parseJsonEvents(result.stdout) expect(result.exitCode).toBe(0) - expect(events.map((event) => event.type)).toEqual([ - "step_start", - "text", - "tool_use", - "step_finish", - "step_start", - "step_finish", - ]) + expect(events.map((event) => event.type)).toEqual(["step_start", "text", "tool_use", "step_finish"]) expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" })) - expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" })) + expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish" })) }), 60_000, ) @@ -245,29 +239,29 @@ describe("opencode run (non-interactive subprocess)", () => { "rejects requested permissions by default and allows them with the dangerous flag", ({ home, llm, opencode }) => Effect.gen(function* () { - yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" }) + yield* llm.tool("shell", { command: "rm -f denied-file", description: "Remove a test file" }) yield* llm.text("continued after rejection") - const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } }) + const denied = yield* opencode.run("request permission", { permission: { shell: "ask" } }) opencode.expectExit(denied, 0) - expect(denied.stderr).toContain("permission requested: bash") + expect(denied.stderr).toContain("permission requested: shell") expect(denied.stdout).toBe("") yield* llm.reset - yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" }) + yield* llm.tool("shell", { command: "rm -f allowed-file", description: "Remove a test file" }) yield* llm.text("continued after approval") const allowed = yield* opencode.run("request permission", { - permission: { bash: "ask" }, + permission: { shell: "ask" }, extraArgs: ["--dangerously-skip-permissions"], }) opencode.expectExit(allowed, 0) - expect(allowed.stderr).not.toContain("permission requested: bash") + expect(allowed.stderr).not.toContain("permission requested: shell") expect(allowed.stdout).toContain("continued after approval") yield* llm.reset - yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" }) + yield* llm.tool("shell", { command: "touch explicitly-denied", description: "Create a denied marker" }) yield* llm.text("continued after explicit denial") const explicitlyDenied = yield* opencode.run("request denied permission", { - permission: { bash: "deny" }, + permission: { shell: "deny" }, extraArgs: ["--dangerously-skip-permissions"], }) opencode.expectExit(explicitlyDenied, 0) @@ -277,6 +271,135 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + cliIt.concurrent( + "rejects unattended questions without hanging", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("question", { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Continue execution" }], + }, + ], + }) + const result = yield* opencode.run("ask a question") + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("") + }), + 60_000, + ) + + cliIt.concurrent( + "continues a current session with projected history", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { OPENCODE_DB: `${home}/run-continue.sqlite` } + yield* llm.text("first response") + const first = yield* opencode.run("first prompt", { env }) + opencode.expectExit(first, 0) + + yield* llm.text("second response") + const second = yield* opencode.run("second prompt", { env, extraArgs: ["--continue"] }) + opencode.expectExit(second, 0) + expect(second.stdout).toBe("second response\n") + expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt") + }), + 60_000, + ) + + cliIt.concurrent( + "forks the latest current session for --continue", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { OPENCODE_DB: `${home}/run-fork-continue.sqlite` } + yield* llm.text("first response") + const first = yield* opencode.run("first prompt", { env, format: "json" }) + opencode.expectExit(first, 0) + const firstSessionID = opencode.parseJsonEvents(first.stdout)[0]?.sessionID + expect(typeof firstSessionID).toBe("string") + + yield* llm.text("forked response") + const second = yield* opencode.run("second prompt", { + env, + format: "json", + extraArgs: ["--continue", "--fork"], + }) + + opencode.expectExit(second, 0) + const secondSessionID = String(opencode.parseJsonEvents(second.stdout)[0]?.sessionID) + expect(secondSessionID).not.toBe(String(firstSessionID)) + expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt") + }), + 60_000, + ) + + cliIt.concurrent( + "forks a current session selected by --session", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { OPENCODE_DB: `${home}/run-fork-session.sqlite` } + yield* llm.text("first response") + const first = yield* opencode.run("first prompt", { env, format: "json" }) + opencode.expectExit(first, 0) + const firstSessionID = opencode.parseJsonEvents(first.stdout)[0]?.sessionID + expect(typeof firstSessionID).toBe("string") + + yield* llm.text("forked response") + const second = yield* opencode.run("second prompt", { + env, + format: "json", + extraArgs: ["--session", String(firstSessionID), "--fork"], + }) + + opencode.expectExit(second, 0) + const secondSessionID = String(opencode.parseJsonEvents(second.stdout)[0]?.sessionID) + expect(secondSessionID).not.toBe(String(firstSessionID)) + expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt") + }), + 60_000, + ) + + cliIt.concurrent( + "applies a variant to the configured default model", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("variant response") + const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], { + config: { ...testProviderConfig(llm.url), model: "test/test-model" }, + }) + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("variant response\n") + }), + 60_000, + ) + + cliIt.live( + "preserves local image files as media attachments", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const source = `${home}/image.png` + yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64"))) + yield* llm.text("attachment received") + const config = testProviderConfig(llm.url) + config.provider.test.models["test-model"].attachment = true + + const result = yield* opencode.run("read the attachment", { + extraArgs: [`--file=${source}`, "--"], + config, + }) + + opencode.expectExit(result, 0) + const input = JSON.stringify(yield* llm.inputs) + expect(input).toContain("image/png") + expect(input).not.toContain("") + }), + 60_000, + ) + cliIt.live( "attach mode sends client-local file contents without a shared path", ({ home, llm, opencode }) => @@ -328,4 +451,19 @@ describe("opencode run (non-interactive subprocess)", () => { }), 30_000, ) + + cliIt.live( + "SIGINT before admission prevents provider execution", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.hang + const run = yield* opencode.startRun("do not start") + run.interrupt() + const result = yield* run.result + + expect(result.exitCode).not.toBe(0) + expect(yield* llm.inputs).toHaveLength(0) + }), + 30_000, + ) }) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index 9ba69a6c8e..59a0d8e7c5 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,58 +1,71 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" +import { OpencodeClient } from "@opencode-ai/sdk/v2" import type { Resolved } from "@opencode-ai/tui/config" import { TuiConfig } from "@/config/tui" import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -function model(id: string, providerID: string, context: number, variants?: Record>) { +function ok(data: T) { + return Promise.resolve({ + data, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) +} + +function provider(id: string, name: string) { + return { + id, + name, + api: { type: "native" as const, settings: {} }, + request: { headers: {}, body: {} }, + } +} + +function model(id: string, providerID: string, context: number, variants: string[] = []) { return { id, providerID, api: { id: providerID, - url: `https://${providerID}.test`, - npm: `@ai-sdk/${providerID}`, + type: "native" as const, + settings: {}, }, name: id, capabilities: { - temperature: true, - reasoning: true, - attachment: true, - 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, + tools: true, + input: ["text"], + output: ["text"], }, - cost: { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, + request: { + headers: {}, + body: {}, }, + variants: variants.map((variant) => ({ + id: variant, + headers: {}, + body: {}, + })), + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], limit: { context, output: 8192, }, status: "active" as const, - options: {}, - headers: {}, - release_date: "2026-01-01", - variants, + enabled: true, } } @@ -160,119 +173,101 @@ describe("run runtime boot", () => { await expect(resolveDiffStyle()).resolves.toBe("auto") }) - test("prefers configured providers for model selector data", async () => { + test("loads v2 providers and models for model selector data", async () => { const sdk = new OpencodeClient() - const data: { - all: Provider[] - default: Record - connected: string[] - } = { - all: [ + const providers = [provider("openai", "OpenAI")] + const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])] + // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) + + await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ + providers: [ { id: "openai", name: "OpenAI", - source: "api", - env: [], - options: {}, models: { - "gpt-5": model("gpt-5", "openai", 128000, { - high: {}, - minimal: {}, - }), - }, - }, - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - sonnet: model("sonnet", "anthropic", 200000), + "gpt-5": { + id: "gpt-5", + providerID: "openai", + name: "gpt-5", + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + variants: { + high: {}, + minimal: {}, + }, + }, }, }, ], - default: {}, - connected: [], - } - const configured = { - providers: [data.all[0]!], - default: {}, - } - const list = spyOn(sdk.provider, "list").mockImplementation(() => - Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) - spyOn(sdk.config, "providers").mockImplementation(() => - Promise.resolve({ - data: configured, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) - - await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ - providers: configured.providers, variants: ["high", "minimal"], limits: { "openai/gpt-5": 128000, }, }) - expect(list).not.toHaveBeenCalled() + expect(providerList).toHaveBeenCalledWith( + { + location: { + directory: "/workspace", + }, + }, + { throwOnError: true }, + ) }) - test("falls back to provider list when configured providers are unavailable", async () => { + test("loads context limits across v2 providers", async () => { const sdk = new OpencodeClient() - const data: { - all: Provider[] - default: Record - connected: string[] - } = { - all: [ - { - id: "openai", - name: "OpenAI", - source: "api", - env: [], - options: {}, - models: { - "gpt-5": model("gpt-5", "openai", 128000, { - high: {}, - minimal: {}, - }), - }, - }, - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - sonnet: model("sonnet", "anthropic", 200000), - }, - }, - ], - default: {}, - connected: [], - } - spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom")) - spyOn(sdk.provider, "list").mockImplementation(() => - Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) + const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")] + const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)] + // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ - providers: data.all, + providers: [ + expect.objectContaining({ + id: "openai", + name: "OpenAI", + models: expect.objectContaining({ + "gpt-5": expect.objectContaining({ + variants: { + high: {}, + minimal: {}, + }, + }), + }), + }), + expect.objectContaining({ + id: "anthropic", + name: "Anthropic", + models: expect.objectContaining({ + sonnet: expect.objectContaining({ + variants: {}, + }), + }), + }), + ], variants: ["high", "minimal"], limits: { "openai/gpt-5": 128000, diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 2c9eb2bd3d..9ad7e003b2 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -3,44 +3,18 @@ import { OpencodeClient } from "@opencode-ai/sdk/v2" import { runInteractiveMode } from "@/cli/cmd/run/runtime" import type { FooterApi, RunProvider } from "@/cli/cmd/run/types" -type SessionMessage = NonNullable>["data"]>[number] - const provider: RunProvider = { id: "openai", name: "OpenAI", - source: "api", - env: [], - options: {}, models: { "gpt-5": { id: "gpt-5", providerID: "openai", - api: { - id: "openai", - url: "https://openai.test", - npm: "@ai-sdk/openai", - }, name: "Little Frank", capabilities: { - temperature: true, - reasoning: true, - attachment: true, - 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, + tools: true, + input: ["text"], + output: ["text"], }, cost: { input: 0, @@ -55,9 +29,7 @@ const provider: RunProvider = { output: 8192, }, status: "active", - options: {}, - headers: {}, - release_date: "2026-01-01", + variants: {}, }, }, } @@ -141,44 +113,129 @@ describe("run interactive runtime", () => { const providers = defer() const sdk = new OpencodeClient() - spyOn(sdk.config, "providers").mockImplementation(async () => { + const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) + const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused")) + const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused")) + spyOn(sdk.v2.provider, "list").mockImplementation(async () => { providersStarted.resolve() await providers.promise - return ok({ providers: [provider], default: {} }) + return ok({ + location: { + directory: "/tmp", + }, + data: [ + { + id: "openai", + name: "OpenAI", + api: { + type: "native", + settings: {}, + }, + request: { + headers: {}, + body: {}, + }, + }, + ], + }) as never }) - spyOn(sdk.session, "messages").mockImplementation(() => - ok([ - { - info: { + spyOn(sdk.v2.model, "list").mockImplementation(() => + ok({ + location: { + directory: "/tmp", + }, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { + id: "openai", + type: "native", + settings: {}, + }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + request: { + headers: {}, + body: {}, + }, + variants: [], + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], + status: "active", + enabled: true, + limit: { + context: 128000, + output: 8192, + }, + }, + ], + }) as never, + ) + spyOn(sdk.v2.session, "messages").mockImplementation(() => + ok({ + data: [ + { id: "msg-user-1", - sessionID: "ses-1", - role: "user", + type: "user", + text: "hello", time: { created: 1, }, - agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - variant: undefined, + }, + ], + cursor: {}, + }), + ) + spyOn(sdk.v2.session, "get").mockImplementation(() => + ok({ + data: { + id: "ses-1", + projectID: "pro-1", + title: "Session", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, }, }, - parts: [ - { - id: "part-user-1", - sessionID: "ses-1", - messageID: "msg-user-1", - type: "text", - text: "hello", - }, - ], - } satisfies SessionMessage, - ]), + time: { + created: 1, + updated: 1, + }, + location: { + directory: "/tmp", + }, + model: { + providerID: "openai", + id: "gpt-5", + }, + }, + }), ) - spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed")) - spyOn(sdk.app, "agents").mockImplementation(() => ok([])) - spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({})) - spyOn(sdk.command, "list").mockImplementation(() => ok([])) + spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) const task = runInteractiveMode( { @@ -215,6 +272,7 @@ describe("run interactive runtime", () => { }, 0) return { runPromptTurn: async () => {}, + interruptActiveTurn: async () => {}, selectSubagent: () => {}, replayOnResize: async () => false, close: async () => {}, @@ -234,5 +292,8 @@ describe("run interactive runtime", () => { await task expect(transportProviders).toEqual([[provider]]) + expect(legacyProviders).not.toHaveBeenCalled() + expect(legacyAgents).not.toHaveBeenCalled() + expect(legacyCommands).not.toHaveBeenCalled() }) }) diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index b685fb679f..ec21cd007e 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk/v2" -import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data" +import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data" import type { StreamCommit } from "@/cli/cmd/run/types" function reduce(data: ReturnType, event: unknown, thinking = true) { @@ -547,28 +547,6 @@ describe("run session data", () => { ]) }) - test("flushInterrupted emits one interrupted final per live part", () => { - const data = reduce( - createSessionData(), - text({ - id: "txt-1", - messageID: "msg-1", - text: "unfinished", - }), - ).data - - const first: StreamCommit[] = [] - flushInterrupted(data, first) - expect(first).toEqual([ - expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }), - expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }), - ]) - - const next: StreamCommit[] = [] - flushInterrupted(data, next) - expect(next).toEqual([]) - }) - test("surfaces session errors as error commits", () => { const out = reduce(createSessionData(), { type: "session.error", diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts deleted file mode 100644 index 7f0f272d63..0000000000 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ /dev/null @@ -1,691 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay" -import type { SessionMessages } from "@/cli/cmd/run/session.shared" -import type { RunProvider } from "@/cli/cmd/run/types" - -function userMessage(id: string, text: string): SessionMessages[number] { - return { - info: { - id, - sessionID: "session-1", - role: "user", - time: { - created: 1, - }, - agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, - }, - parts: [ - { - id: `${id}-text`, - sessionID: "session-1", - messageID: id, - type: "text", - text, - }, - ], - } -} - -function assistantInfo( - id: string, - input: { - parentID?: string - modelID?: string - providerID?: string - time?: { created: number; completed?: number } - } = {}, -) { - return { - id, - sessionID: "session-1", - role: "assistant" as const, - time: input.time ?? { created: 2 }, - parentID: input.parentID ?? "msg-user-1", - modelID: input.modelID ?? "gpt-5", - providerID: input.providerID ?? "openai", - mode: "chat", - agent: "build", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } -} - -function assistantMessage( - id: string, - text: string, - input: { - parentID?: string - modelID?: string - providerID?: string - time?: { created: number; completed?: number } - } = {}, -): SessionMessages[number] { - const time = input.time ?? { - created: 200, - completed: 3000, - } - - return { - info: assistantInfo(id, { - ...input, - time, - }), - parts: [ - { - id: `${id}-text`, - sessionID: "session-1", - messageID: id, - type: "text", - text, - time: { - start: time.created, - end: time.completed, - }, - }, - ], - } -} - -const provider = (name: string): RunProvider => ({ - id: "openai", - name: "OpenAI", - source: "api", - env: [], - options: {}, - models: { - "gpt-5": { - id: "gpt-5", - providerID: "openai", - api: { - id: "openai", - url: "https://openai.test", - npm: "@ai-sdk/openai", - }, - name, - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - 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: 128000, - output: 8192, - }, - status: "active", - options: {}, - headers: {}, - release_date: "2026-01-01", - }, - }, -}) - -function runningToolMessage(id: string): SessionMessages[number] { - return { - info: assistantInfo(id), - parts: [ - { - id: `${id}-tool`, - sessionID: "session-1", - messageID: id, - type: "tool", - callID: `${id}-call`, - tool: "bash", - state: { - status: "running", - input: { - command: "pwd", - }, - time: { - start: 2, - }, - }, - }, - ], - } -} - -function shellUserMessage(id: string): SessionMessages[number] { - return { - info: { - id, - sessionID: "session-1", - role: "user", - time: { - created: 1, - }, - agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, - }, - parts: [ - { - id: `${id}-text`, - sessionID: "session-1", - messageID: id, - type: "text", - text: "The following tool was executed by the user", - synthetic: true, - }, - ], - } -} - -function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] { - return { - info: assistantInfo(id, { - parentID, - time: { - created: 200, - completed: 3000, - }, - }), - parts: [ - { - id: `${id}-tool`, - sessionID: "session-1", - messageID: id, - type: "tool", - callID: `${id}-call`, - tool: "bash", - state: { - status: "completed", - input: { - command: "ls", - }, - output: "account.ts\n", - title: "", - metadata: { - output: "account.ts\n", - }, - time: { - start: 200, - end: 3000, - }, - }, - }, - ], - } -} - -describe("run session replay", () => { - test("replays persisted user, assistant, and turn summary history into scrollback commits", () => { - const out = replaySession({ - messages: [ - userMessage("msg-user-1", "Hello, whats the weather today?"), - assistantMessage("msg-1", "What city or ZIP code should I check?"), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.commits).toEqual([ - expect.objectContaining({ - kind: "user", - text: "Hello, whats the weather today?", - phase: "start", - source: "system", - messageID: "msg-user-1", - }), - expect.objectContaining({ - kind: "assistant", - text: "What city or ZIP code should I check?", - phase: "progress", - source: "assistant", - messageID: "msg-1", - }), - expect.objectContaining({ - kind: "system", - text: "Build · gpt-5 · 2.8s", - phase: "final", - source: "system", - messageID: "msg-1", - summary: { - agent: "Build", - model: "gpt-5", - duration: "2.8s", - }, - }), - ]) - expect(out.patch).toEqual( - expect.objectContaining({ - phase: "idle", - status: "", - }), - ) - }) - - test("uses provider model names for replayed turn summaries when available", () => { - const out = replaySession({ - messages: [ - userMessage("msg-user-1", "Hello, whats the weather today?"), - assistantMessage("msg-1", "What city or ZIP code should I check?"), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - providers: [provider("Little Frank")], - }) - - expect(out.commits.at(-1)).toEqual( - expect.objectContaining({ - kind: "system", - text: "Build · Little Frank · 2.8s", - summary: { - agent: "Build", - model: "Little Frank", - duration: "2.8s", - }, - }), - ) - }) - - test("replays one turn summary for the final assistant in a multi-step turn", () => { - const out = replaySession({ - messages: [ - userMessage("msg-user-1", "Plan and then answer"), - assistantMessage("msg-step-1", "Working", { - parentID: "msg-user-1", - time: { created: 200, completed: 900 }, - }), - assistantMessage("msg-step-2", "Done", { - parentID: "msg-user-1", - time: { created: 1000, completed: 3000 }, - }), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.commits.filter((commit) => commit.summary)).toEqual([ - expect.objectContaining({ - kind: "system", - text: "Build · gpt-5 · 2.0s", - messageID: "msg-step-2", - }), - ]) - }) - - test("keeps the footer in a running state for resumed active tools", () => { - const out = replaySession({ - messages: [runningToolMessage("msg-1")], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.patch).toEqual( - expect.objectContaining({ - phase: "running", - status: "running bash", - }), - ) - }) - - test("does not replay turn summaries for shell-mode commands", () => { - const out = replaySession({ - messages: [ - shellUserMessage("msg-shell-user-1"), - shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.commits.some((commit) => commit.summary)).toBe(false) - expect(out.commits).toContainEqual( - expect.objectContaining({ - kind: "tool", - text: "account.ts\n", - tool: "bash", - toolState: "completed", - }), - ) - }) - - test("merges failed local rows ahead of later persisted prompts", () => { - const persisted = { - kind: "user", - text: "successful", - phase: "start", - source: "system", - messageID: "msg-user-2", - } as const - const failed = { - kind: "user", - text: "failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "network unavailable", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]), - ).toEqual([failed, error, persisted]) - }) - - test("retains local errors but not duplicate local prompts once a prompt persists", () => { - const persisted = { - kind: "user", - text: "failed after persistence", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "connection closed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "failed after persistence")], - [persisted], - [{ commit: persisted }, { commit: error }], - ), - ).toEqual([persisted, error]) - }) - - test("keeps a local turn failure below assistant output already visible for that turn", () => { - const first = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const answer = { - kind: "assistant", - text: "partial answer", - phase: "progress", - source: "assistant", - messageID: "msg-assistant-1", - } as const - const error = { - kind: "error", - text: "stream failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const second = { - kind: "user", - text: "retry", - phase: "start", - source: "system", - messageID: "msg-user-2", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")], - [first, answer, second], - [ - { - commit: error, - after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" }, - }, - ], - ), - ).toEqual([first, answer, error, second]) - }) - - test("keeps a local failure above assistant output received after the failure", () => { - const first = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "request failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const late = { - kind: "assistant", - text: "late answer", - phase: "progress", - source: "assistant", - messageID: "msg-assistant-1", - } as const - - expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([ - first, - error, - late, - ]) - }) - - test("inserts a local failure between persisted output chunks spanning that failure", () => { - const first = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const complete = { - kind: "assistant", - text: "before after", - phase: "progress", - source: "assistant", - messageID: "msg-assistant-1", - partID: "part-1", - } as const - const error = { - kind: "error", - text: "stream failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "start")], - [first, complete], - [ - { - commit: error, - after: { - kind: "assistant", - text: "before ", - phase: "progress", - messageID: "msg-assistant-1", - partID: "part-1", - visible: "before ", - }, - }, - ], - ), - ).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }]) - }) - - test("places an unpersisted failed prompt before live output from that turn", () => { - const prompt = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-1", - } as const - const answer = { - kind: "assistant", - text: "partial answer", - phase: "progress", - source: "assistant", - messageID: "msg-2", - } as const - const error = { - kind: "error", - text: "stream failed", - phase: "start", - source: "system", - messageID: "msg-1", - } as const - - expect( - replayLocalRows( - [], - [answer], - [ - { commit: prompt }, - { - commit: error, - after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" }, - }, - ], - ), - ).toEqual([prompt, answer, error]) - }) - - test("anchors a failure after the visible start of a tool that later completes", () => { - const prompt = { - kind: "user", - text: "run ls", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const running = { - kind: "tool", - text: "running bash", - phase: "start", - source: "tool", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "running", - } as const - const completed = { - kind: "tool", - text: "file.txt", - phase: "final", - source: "tool", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "completed", - } as const - const error = { - kind: "error", - text: "connection lost", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "run ls")], - [prompt, running, completed], - [ - { - commit: error, - after: { - kind: "tool", - text: "running bash", - phase: "start", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "running", - }, - }, - ], - ), - ).toEqual([prompt, running, error, completed]) - }) - - test("retains an unpersisted local diagnostic before later persisted prompts", () => { - const first = { - kind: "user", - text: "before", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "failed to start new session", - phase: "start", - source: "system", - messageID: "msg-user-2", - } as const - const second = { - kind: "user", - text: "after", - phase: "start", - source: "system", - messageID: "msg-user-3", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], - [first, second], - [{ commit: error }], - ), - ).toEqual([first, error, second]) - }) -}) diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index 5a7e1bff98..a470f51014 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpencodeClient } from "@opencode-ai/sdk/v2" import { createSession, + resolveCurrentSession, sessionHistory, sessionVariant, type RunSession, @@ -18,6 +20,10 @@ const model = { modelID: "gpt-5", } +afterEach(() => { + mock.restore() +}) + function userMessage(id: string, parts: Message["parts"], variant = "high"): Message { return { info: { @@ -244,4 +250,74 @@ describe("run session shared", () => { expect(sessionVariant(session, model)).toBe("minimal") }) + + test("restores current prompt history from stored text and file references", async () => { + const client = new OpencodeClient() + spyOn(client.v2.session, "messages").mockImplementation(() => + Promise.resolve({ + data: { + data: [ + { + id: "msg_prompt", + type: "user", + text: "Review @note.ts", + files: [ + { + uri: "file:///tmp/note.ts", + mime: "text/plain", + name: "note.ts", + source: { start: 7, end: 15, text: "@note.ts" }, + }, + ], + agents: [], + time: { created: 1 }, + }, + ], + cursor: {}, + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + spyOn(client.v2.session, "get").mockImplementation(() => + Promise.resolve({ + data: { + data: { + id: "ses_1", + title: "Session", + version: "dev", + projectID: "proj_1", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + time: { created: 1, updated: 1 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model: { providerID: "openai", id: "gpt-5", variant: "high" }, + }, + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + + const out = await resolveCurrentSession(client, "ses_1") + + expect(out.turns[0]?.prompt).toEqual({ + text: "Review @note.ts", + parts: [ + { + type: "file", + url: "file:///tmp/note.ts", + mime: "text/plain", + filename: "note.ts", + source: { + type: "file", + path: "note.ts", + text: { start: 7, end: 15, value: "@note.ts" }, + }, + }, + ], + }) + }) }) diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts new file mode 100644 index 0000000000..e1b412c5f6 --- /dev/null +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -0,0 +1,1302 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "node:url" +import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" +import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport" +import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" +import { tmpdir } from "../../fixture/fixture" + +type RunV2Event = V2Event + +function feed() { + const values: RunV2Event[] = [] + let closed = false + let wake: (() => void) | undefined + const stream = (async function* (): AsyncGenerator { + while (!closed || values.length > 0) { + if (values.length === 0) { + await new Promise((resolve) => { + wake = resolve + }) + continue + } + const value = values.shift() + if (value) yield value + } + })() + return { + stream, + push(value: RunV2Event) { + values.push(value) + wake?.() + wake = undefined + }, + close() { + closed = true + wake?.() + wake = undefined + }, + } +} + +function ok(data: T) { + return Promise.resolve({ + data, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) +} + +function connected(id = "evt_connected") { + return { id, type: "server.connected", data: {} } satisfies RunV2Event +} + +function footer() { + const commits: StreamCommit[] = [] + const events: FooterEvent[] = [] + let closed = false + const api: FooterApi = { + get isClosed() { + return closed + }, + onPrompt: () => () => {}, + onQueuedRemove: () => () => {}, + onClose: () => () => {}, + event(value) { + events.push(value) + }, + append(value) { + commits.push(value) + }, + idle: () => Promise.resolve(), + close() { + closed = true + }, + destroy() { + closed = true + }, + } + return { api, commits, events } +} + +type SessionMessages = NonNullable< + Awaited>["data"] +>["data"][number][] + +function sdk(input: { + streams: ReturnType[] + active?: () => Record + messages?: Record + sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }> +}) { + const client = new OpencodeClient() + let subscription = 0 + spyOn(client.v2.event, "subscribe").mockImplementation( + () => Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType, + ) + spyOn(client.v2.session, "messages").mockImplementation((request) => + ok({ + data: input.messages?.[request.sessionID] ?? [ + { + id: "msg_old", + type: "user" as const, + text: "previous prompt", + files: [], + agents: [], + time: { created: 1 }, + }, + ], + cursor: {}, + }), + ) + spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] })) + spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] })) + spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {} })) + spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined)) + spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + // The generated methods have conditional return types for throwOnError; the + // minimal shapes below are enough for family discovery and model fallback. + spyOn(client.v2.session, "list").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: input.sessions ?? [], + }) as never, + ) + spyOn(client.v2.model, "default").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: undefined, + }) as never, + ) + return client +} + +afterEach(() => { + mock.restore() +}) + +describe("V2 mini transport", () => { + test("hydrates projection, reduces live output, and completes on settlement", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"]) + + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ + data: { + admittedSeq: 1, + id: messageID, + sessionID: "ses_1", + prompt, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_text", + type: "session.next.text.delta", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + textID: "txt_1", + delta: "answer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 4, sessionID: "ses_1", outcome: "success" }, + }) + await turn + + expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt", "answer"]) + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } }) + await transport.close() + }) + + test("sends local file and directory mentions as structured prompt files", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "note.ts") + const directoryPath = path.join(tmp.path, "docs") + await Bun.write(filePath, "export const answer = 42\n") + await fs.mkdir(directoryPath) + await Bun.write(path.join(directoryPath, "README.md"), "# hello\n") + + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: + | Parameters[0] + | undefined + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: input.prompt?.text ?? "" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID: "ses_1", + prompt: input.prompt ?? { text: "" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_prompt", + text: "Review @note.ts and @docs", + parts: [ + { + type: "file", + url: pathToFileURL(filePath).href, + mime: "text/plain", + filename: "note.ts", + source: { type: "file", path: "note.ts", text: { start: 7, end: 15, value: "@note.ts" } }, + }, + { + type: "file", + url: pathToFileURL(`${directoryPath}${path.sep}`).href, + mime: "application/x-directory", + filename: "docs", + source: { type: "file", path: "docs/", text: { start: 20, end: 25, value: "@docs" } }, + }, + ], + }, + files: [], + includeFiles: true, + }) + + expect(request?.prompt?.text).toBe("Review @note.ts and @docs") + expect(request?.prompt?.files).toEqual([ + { + uri: pathToFileURL(filePath).href, + name: "note.ts", + source: { start: 7, end: 15, text: "@note.ts" }, + }, + { + uri: pathToFileURL(`${directoryPath}${path.sep}`).href, + name: "docs", + source: { start: 20, end: 25, text: "@docs" }, + }, + ]) + await transport.close() + }) + + test("sends attached file mentions as structured prompt files without reading them", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const remoteRead = spyOn(client.file, "read") + const remoteList = spyOn(client.file, "list") + const transport = await createSessionTransport({ + sdk: client, + directory: "/remote/project", + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: + | Parameters[0] + | undefined + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: input.prompt?.text ?? "" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID: "ses_1", + prompt: input.prompt ?? { text: "" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_prompt", + text: "Review @note.ts and @docs", + parts: [ + { + type: "file", + url: "file:///remote/project/note.ts", + mime: "text/plain", + filename: "note.ts", + source: { type: "file", path: "note.ts", text: { start: 7, end: 15, value: "@note.ts" } }, + }, + { + type: "file", + url: "file:///remote/project/docs", + mime: "application/x-directory", + filename: "docs", + source: { type: "file", path: "docs", text: { start: 20, end: 25, value: "@docs" } }, + }, + ], + }, + files: [], + includeFiles: true, + }) + + expect(remoteRead).not.toHaveBeenCalled() + expect(remoteList).not.toHaveBeenCalled() + expect(request?.prompt?.text).toBe("Review @note.ts and @docs") + expect(request?.prompt?.files).toEqual([ + { + uri: "file:///remote/project/note.ts", + name: "note.ts", + source: { start: 7, end: 15, text: "@note.ts" }, + }, + { + uri: "file:///remote/project/docs", + name: "docs", + source: { start: 20, end: 25, text: "@docs" }, + }, + ]) + await transport.close() + }) + + test("sends local media mentions as structured prompt files", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "diagram.png") + await Bun.write(filePath, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00)) + + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: + | Parameters[0] + | undefined + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: input.prompt?.text ?? "" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID: "ses_1", + prompt: input.prompt ?? { text: "" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_prompt", + text: "Review @diagram.png", + parts: [ + { + type: "file", + url: pathToFileURL(filePath).href, + mime: "text/plain", + filename: "diagram.png", + source: { type: "file", path: "diagram.png", text: { start: 7, end: 19, value: "@diagram.png" } }, + }, + ], + }, + files: [], + includeFiles: true, + }) + + expect(request?.prompt?.text).toBe("Review @diagram.png") + expect(request?.prompt?.files).toEqual([ + { + name: "diagram.png", + uri: pathToFileURL(filePath).href, + source: { start: 7, end: 19, text: "@diagram.png" }, + }, + ]) + await transport.close() + }) + + test("shows V2 blockers and replies through the runtime-owned session API", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_permission", + type: "permission.v2.asked", + data: { id: "per_1", sessionID: "ses_1", action: "read", resources: ["/tmp/file"] }, + }) + + await Bun.sleep(0) + expect(ui.events).toContainEqual({ + type: "stream.view", + view: { + type: "permission", + request: { + id: "per_1", + sessionID: "ses_1", + permission: "read", + patterns: ["/tmp/file"], + metadata: {}, + always: [], + tool: undefined, + }, + }, + }) + await transport.close() + }) + + test("rebootstraps after disconnect and completes a promoted turn from idle active state", async () => { + const first = feed() + const second = feed() + first.push(connected("evt_connected_1")) + second.push(connected("evt_connected_2")) + let running = true + const client = sdk({ + streams: [first, second], + active: () => { + const active: Record = {} + if (running) active.ses_1 = { type: "running" } + return active + }, + }) + let projected = false + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: projected + ? [ + { + id: "msg_prompt", + type: "user", + text: "hello", + files: [], + agents: [], + time: { created: 2 }, + }, + ] + : [], + cursor: {}, + }), + ) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + projected = true + running = false + first.close() + await turn + + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "reconnecting" } }) + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } }) + await transport.close() + }) + + test("does not duplicate the optimistic user row when reconnect hydration recovers a missed prompt", async () => { + const first = feed() + const second = feed() + first.push(connected("evt_connected_1")) + second.push(connected("evt_connected_2")) + let running = true + let projected = false + const client = sdk({ + streams: [first, second], + active: () => { + const active: Record = {} + if (running) active.ses_1 = { type: "running" } + return active + }, + }) + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: projected + ? [ + { + id: "msg_prompt", + type: "user", + text: "hello", + files: [], + agents: [], + time: { created: 2 }, + }, + ] + : [], + cursor: {}, + }), + ) + const ui = footer() + ui.commits.push({ kind: "user", source: "system", text: "hello", phase: "start", messageID: "msg_prompt" }) + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + projected = true + running = false + first.close() + await turn + + expect(ui.commits.filter((item) => item.kind === "user" && item.messageID === "msg_prompt")).toHaveLength(1) + await transport.close() + }) + + test("reconciles buffered deltas already present in a resize snapshot", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: [ + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [{ type: "text", id: "txt_1", text: "the answer" }], + time: { created: 2, completed: 3 }, + }, + ], + cursor: {}, + }), + ) + let reset!: () => void + const resetting = new Promise((resolve) => { + reset = resolve + }) + const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) + events.push({ + id: "evt_text", + type: "session.next.text.delta", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + textID: "txt_1", + delta: "answer", + }, + }) + await Bun.sleep(0) + reset() + await replay + + expect(ui.commits.filter((item) => item.text === "the answer")).toHaveLength(1) + expect(ui.commits.some((item) => item.text === "answer")).toBe(false) + await transport.close() + }) + + test("scopes repeated text and reasoning ids by assistant message", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: [ + { + id: "msg_b", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [ + { type: "reasoning", id: "reasoning-0", text: "second thought" }, + { type: "text", id: "text-0", text: "second answer" }, + ], + time: { created: 4, completed: 5 }, + }, + { + id: "msg_a", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [ + { type: "reasoning", id: "reasoning-0", text: "first thought" }, + { type: "text", id: "text-0", text: "first answer" }, + ], + time: { created: 2, completed: 3 }, + }, + ], + cursor: {}, + }), + ) + + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + + expect(ui.commits.map((item) => item.text)).toEqual([ + "Thinking: first thought", + "first answer", + "Thinking: second thought", + "second answer", + ]) + await transport.close() + }) + + test("renders full reasoning when only the ended event is observed", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_reasoning", + type: "session.next.reasoning.ended", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + reasoningID: "reasoning_1", + text: "considering", + }, + }) + await Bun.sleep(0) + + expect(ui.commits.at(-1)?.text).toBe("Thinking: considering") + await transport.close() + }) + + test("resolves an interrupted turn even when promotion never arrived", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + active: () => ({ ses_1: { type: "running" } }), + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + await transport.interruptActiveTurn() + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + }) + await turn + + expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" }) + await transport.close() + }) + + test("falls back to the default model when selecting a variant on a fresh session", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + // The generated method has conditional return types for throwOnError; the test only needs the nested model field. + // @ts-expect-error minimal session shape is enough for this lookup + spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } })) + spyOn(client.v2.model, "default").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: { id: "gpt-5", providerID: "openai" }, + }) as never, + ) + const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: "high", + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + await turn + + expect(switched).toHaveBeenCalledWith( + { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, + expect.objectContaining({ throwOnError: true }), + ) + await transport.close() + }) + + test("interrupts the current Session when an active turn is aborted", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const controller = new AbortController() + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + signal: controller.signal, + }) + while (!admitted) await Bun.sleep(0) + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + await Bun.sleep(0) + controller.abort() + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + }) + await turn + + expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" }) + await transport.close() + }) + + test("discovers a live child session and tracks its tab and selected detail", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_child: [ + { + id: "msg_task", + type: "user" as const, + text: "task prompt", + files: [], + agents: [], + time: { created: 1 }, + }, + ], + }, + }) + spyOn(client.v2.session, "get").mockImplementation(() => + ok({ + data: { + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + }, + }), + ) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => + ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + transport.selectSubagent("ses_child") + + events.push({ + id: "evt_child_step", + type: "session.next.step.started", + data: { + timestamp: 2, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + agent: "explore", + model: { providerID: "test", id: "model" }, + }, + }) + while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "task prompt"))) + await Bun.sleep(0) + expect(states().at(-1)?.tabs).toMatchObject([ + { sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" }, + ]) + + events.push({ + id: "evt_child_text", + type: "session.next.text.delta", + data: { + timestamp: 3, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + textID: "txt_child", + delta: "child answer", + }, + }) + while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer"))) + await Bun.sleep(0) + + events.push({ + id: "evt_child_settled", + type: "session.next.execution.settled", + data: { timestamp: 4, sessionID: "ses_child", outcome: "success" }, + }) + while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) + await transport.close() + }) + + test("keeps child terminal state observed during discovery", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + let resolveGet: (() => void) | undefined + const gate = new Promise((resolve) => { + resolveGet = resolve + }) + spyOn(client.v2.session, "get").mockImplementation(async () => { + await gate + return ok({ + data: { + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + }, + }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => + ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + + // Both events arrive while session.get is still in flight. + events.push({ + id: "evt_child_step", + type: "session.next.step.started", + data: { + timestamp: 2, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + agent: "explore", + model: { providerID: "test", id: "model" }, + }, + }) + events.push({ + id: "evt_child_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_child", outcome: "interrupted" }, + }) + await Bun.sleep(0) + resolveGet?.() + while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) + await transport.close() + }) + + test("does not resurrect a settled child from stale discovery buffer", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + let resolveGet: (() => void) | undefined + const gate = new Promise((resolve) => { + resolveGet = resolve + }) + spyOn(client.v2.session, "get").mockImplementation(async () => { + await gate + return ok({ + data: { + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + }, + }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => + ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + + // Child event arrives first and gets buffered behind the gated session.get. + events.push({ + id: "evt_child_step", + type: "session.next.step.started", + data: { + timestamp: 2, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + agent: "explore", + model: { providerID: "test", id: "model" }, + }, + }) + // Parent's background subagent tool.success adopts the child mid-discovery. + events.push({ + id: "evt_parent_call", + type: "session.next.tool.called", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_parent_a", + callID: "call_sub", + tool: "subagent", + input: { agent: "explore", description: "Find things", prompt: "go", background: true }, + provider: { executed: true }, + }, + }) + events.push({ + id: "evt_parent_success", + type: "session.next.tool.success", + data: { + timestamp: 4, + sessionID: "ses_1", + assistantMessageID: "msg_parent_a", + callID: "call_sub", + structured: { sessionID: "ses_child", status: "running", output: "" }, + content: [], + provider: { executed: true }, + }, + }) + // The settled event arrives after adoption, so it applies directly. + events.push({ + id: "evt_child_settled", + type: "session.next.execution.settled", + data: { timestamp: 5, sessionID: "ses_child", outcome: "interrupted" }, + }) + while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) + + // Resolving discovery must not replay the buffered step.started over the + // terminal status. + const before = states().length + resolveGet?.() + while (states().length === before) await Bun.sleep(0) + await Bun.sleep(0) + await Bun.sleep(0) + expect(states().at(-1)?.tabs).toMatchObject([{ sessionID: "ses_child", status: "cancelled" }]) + await transport.close() + }) + + test("adopts historical children from the session family list", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + sessions: [ + { id: "ses_child_old", parentID: "ses_1", title: "Earlier subagent", agent: "explore", time: { updated: 9 } }, + { id: "ses_unrelated", title: "Different session", time: { updated: 5 } }, + { id: "ses_sibling", parentID: "ses_2", title: "Someone else's child", time: { updated: 4 } }, + ], + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + expect(states.at(-1)?.tabs).toMatchObject([ + { + sessionID: "ses_child_old", + label: "Explore", + title: "Earlier subagent", + status: "completed", + }, + ]) + await transport.close() + }) + + test("hydrates completed subagent children from projected tool output", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_parent", + type: "assistant" as const, + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 1, completed: 3 }, + content: [ + { + type: "tool" as const, + id: "call_sub", + name: "subagent", + state: { + status: "completed" as const, + input: { agent: "explore", description: "Find things", prompt: "go" }, + content: [{ type: "text" as const, text: "done" }], + structured: { sessionID: "ses_child", status: "completed", output: "done" }, + }, + time: { created: 1, ran: 1, completed: 2 }, + }, + ], + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + expect(states.at(-1)?.tabs).toMatchObject([ + { + sessionID: "ses_child", + label: "Explore", + description: "Find things", + status: "completed", + toolCalls: undefined, + }, + ]) + await transport.close() + }) +}) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts deleted file mode 100644 index 5bb578447f..0000000000 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ /dev/null @@ -1,2363 +0,0 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2" -import { createSessionTransport } from "@/cli/cmd/run/stream.transport" -import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types" - -type EventStream = Awaited>["stream"] -type GlobalEventStream = Awaited>["stream"] -type SdkEvent = EventStream extends AsyncGenerator ? T : never -type SessionMessage = NonNullable>["data"]>[number] -type SessionChild = NonNullable>["data"]>[number] -type SessionToolPart = Extract -type SessionStatusMap = NonNullable>["data"]> -type TextPart = Extract -type ReasoningPart = Extract - -afterEach(() => { - mock.restore() -}) - -function defer() { - let resolve!: (value: T | PromiseLike) => void - let reject!: (error?: unknown) => void - const promise = new Promise((next, fail) => { - resolve = next - reject = fail - }) - - return { promise, resolve, reject } -} - -async function waitFor(check: () => T | undefined, timeout = 1_000): Promise { - const end = Date.now() + timeout - while (Date.now() < end) { - const value = check() - if (value !== undefined) { - return value - } - - await Bun.sleep(10) - } - - throw new Error("timed out waiting for value") -} - -function busy(sessionID = "session-1") { - return { - id: `evt-${sessionID}-busy`, - type: "session.status", - properties: { - sessionID, - status: { - type: "busy", - }, - }, - } satisfies SdkEvent -} - -function idle(sessionID = "session-1") { - return { - id: `evt-${sessionID}-idle`, - type: "session.status", - properties: { - sessionID, - status: { - type: "idle", - }, - }, - } satisfies SdkEvent -} - -function retry(sessionID: string, attempt: number, message: string) { - return { - id: `evt-${sessionID}-retry-${attempt}`, - type: "session.status", - properties: { - sessionID, - status: { - type: "retry", - attempt, - message, - next: 1, - }, - }, - } satisfies SdkEvent -} - -function assistant(id: string) { - return { - id: `evt-${id}`, - type: "message.updated", - properties: { - sessionID: "session-1", - info: assistantMessage({ - sessionID: "session-1", - id, - parts: [], - }).info, - }, - } satisfies SdkEvent -} - -const StreamClosed = undefined as never - -function feed(returnValue: R = StreamClosed) { - const list: T[] = [] - let done = false - let wake: (() => void) | undefined - - const wrapped = (async function* (): AsyncGenerator { - while (!done || list.length > 0) { - if (list.length === 0) { - await new Promise((resolve) => { - wake = resolve - }) - continue - } - - const next = list.shift() - if (!next) { - continue - } - - yield next - } - return returnValue as R - })() - - return { - stream: wrapped, - push(value: T) { - list.push(value) - wake?.() - wake = undefined - }, - close() { - done = true - wake?.() - wake = undefined - }, - } -} - -function eventFeed() { - return feed() -} - -function globalFeed() { - return feed() -} - -function emptyStream(): EventStream { - return (async function* (): AsyncGenerator {})() -} - -function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) -} - -function sse(stream: EventStream) { - return Promise.resolve({ stream }) -} - -function globalSse(stream: GlobalEventStream) { - return Promise.resolve({ stream }) -} - -function wrapGlobalStream(stream: EventStream): GlobalEventStream { - return (async function* (): GlobalEventStream { - for await (const event of stream) { - yield globalEvent(event) - } - return StreamClosed - })() -} - -function statusMap(busy: boolean): SessionStatusMap { - if (busy) { - return { "session-1": { type: "busy" } } - } - - return {} -} - -function assistantMessage(input: { sessionID: string; id: string; parts: SessionMessage["parts"] }): SessionMessage { - return { - info: { - id: input.id, - sessionID: input.sessionID, - role: "assistant", - time: { - created: 1, - }, - parentID: "msg-user-1", - modelID: "gpt-5", - providerID: "openai", - mode: "chat", - agent: "build", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - }, - parts: input.parts, - } -} - -function runningTool(input: { - sessionID: string - messageID: string - id: string - callID: string - tool: string - body: Record - metadata?: Record -}): SessionToolPart { - return { - id: input.id, - sessionID: input.sessionID, - messageID: input.messageID, - type: "tool", - callID: input.callID, - tool: input.tool, - state: { - status: "running", - input: input.body, - ...(input.metadata ? { metadata: input.metadata } : {}), - time: { - start: 1, - }, - }, - } -} - -function completedTool(input: { - sessionID: string - messageID: string - id: string - callID: string - tool: string - body: Record - output?: string - metadata?: Record -}): SessionToolPart { - return { - id: input.id, - sessionID: input.sessionID, - messageID: input.messageID, - type: "tool", - callID: input.callID, - tool: input.tool, - state: { - status: "completed", - input: input.body, - output: input.output ?? "", - title: input.tool, - metadata: input.metadata ?? {}, - time: { - start: 1, - end: 2, - }, - }, - } -} - -function textPart(id: string, messageID: string, text: string, sessionID = "session-1"): TextPart { - return { - id, - sessionID, - messageID, - type: "text", - text, - } -} - -function textUpdated(part: TextPart): SdkEvent { - return { - id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 1, - }, - } -} - -function reasoningPart(id: string, messageID: string, text: string): ReasoningPart { - return { - id, - sessionID: "session-1", - messageID, - type: "reasoning", - text, - time: { start: 1 }, - } -} - -function reasoningUpdated(part: ReasoningPart): SdkEvent { - return { - id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 1, - }, - } -} - -function toolUpdated(part: SessionToolPart): SdkEvent { - return { - id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 1, - }, - } -} - -function textDelta(messageID: string, partID: string, delta: string, sessionID = "session-1"): SdkEvent { - return { - id: `evt-${partID}-delta`, - type: "message.part.delta", - properties: { - sessionID, - messageID, - partID, - field: "text", - delta, - }, - } -} - -function child(id: string): SessionChild { - return { - id, - slug: id, - projectID: "project-1", - directory: "/tmp", - title: id, - version: "1", - time: { - created: 1, - updated: 1, - }, - } -} - -function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent { - return { - directory: "/tmp", - project: "project-1", - payload, - } -} - -function footer(fn?: (commit: StreamCommit) => void) { - const commits: StreamCommit[] = [] - const events: FooterEvent[] = [] - let closed = false - let idleCalls = 0 - - const api: FooterApi = { - get isClosed() { - return closed - }, - onPrompt: () => () => {}, - onQueuedRemove: () => () => {}, - onClose: () => () => {}, - event(next) { - events.push(next) - }, - append(next) { - commits.push(next) - fn?.(next) - }, - idle() { - idleCalls += 1 - return Promise.resolve() - }, - close() { - closed = true - }, - destroy() { - closed = true - }, - } - - return { - api, - commits, - events, - get idleCalls() { - return idleCalls - }, - } -} - -function sdk( - input: { - stream?: EventStream - globalStream?: GlobalEventStream - subscribe?: OpencodeClient["event"]["subscribe"] - globalEvent?: OpencodeClient["global"]["event"] - promptAsync?: OpencodeClient["session"]["promptAsync"] - status?: OpencodeClient["session"]["status"] - messages?: OpencodeClient["session"]["messages"] - children?: OpencodeClient["session"]["children"] - permissions?: OpencodeClient["permission"]["list"] - questions?: OpencodeClient["question"]["list"] - } = {}, -) { - const client = new OpencodeClient() - - const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream())) - const globalEvent: OpencodeClient["global"]["event"] = - input.globalEvent ?? (() => globalSse(input.globalStream ?? wrapGlobalStream(input.stream ?? emptyStream()))) - const promptAsync: OpencodeClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined)) - const status: OpencodeClient["session"]["status"] = input.status ?? (() => ok({})) - const messages: OpencodeClient["session"]["messages"] = input.messages ?? (() => ok([])) - const children: OpencodeClient["session"]["children"] = input.children ?? (() => ok([])) - const permissions: OpencodeClient["permission"]["list"] = input.permissions ?? (() => ok([])) - const questions: OpencodeClient["question"]["list"] = input.questions ?? (() => ok([])) - - spyOn(client.event, "subscribe").mockImplementation(subscribe) - spyOn(client.global, "event").mockImplementation(globalEvent) - spyOn(client.session, "promptAsync").mockImplementation(promptAsync) - spyOn(client.session, "status").mockImplementation(status) - spyOn(client.session, "messages").mockImplementation(messages) - spyOn(client.session, "children").mockImplementation(children) - spyOn(client.permission, "list").mockImplementation(permissions) - spyOn(client.question, "list").mockImplementation(questions) - - return client -} - -describe("run stream transport", () => { - test("does not replay persisted main-session history during bootstrap by default", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - sessionID === "session-1" - ? ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - { - ...textPart("text-1", "msg-1", "Hello."), - time: { - start: 1, - end: 2, - }, - }, - ], - }), - ]) - : ok([]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - expect(ui.commits).toEqual([]) - expect(ui.idleCalls).toBe(0) - } finally { - src.close() - await transport.close() - } - }) - - test("replays persisted main-session history during bootstrap when enabled", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - sessionID === "session-1" - ? ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - { - ...textPart("text-1", "msg-1", "Hello."), - time: { - start: 1, - end: 2, - }, - }, - ], - }), - ]) - : ok([]), - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello.")) - expect(ui.idleCalls).toBeGreaterThan(0) - } finally { - src.close() - await transport.close() - } - }) - - test("caps replayed bootstrap history to the configured number of messages", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - ok( - sessionID === "session-1" - ? [ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - { - ...textPart("text-1", "msg-1", "Hello."), - time: { - start: 1, - end: 2, - }, - }, - ], - }), - assistantMessage({ - sessionID: "session-1", - id: "msg-2", - parts: [ - { - ...textPart("text-2", "msg-2", "World."), - time: { - start: 3, - end: 4, - }, - }, - ], - }), - ] - : [], - ), - }), - sessionID: "session-1", - thinking: true, - replay: true, - replayLimit: 1, - limits: () => ({}), - footer: ui.api, - }) - - try { - await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined)) - expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([ - expect.objectContaining({ - text: "World.", - }), - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("skips buffered pre-bootstrap deltas already covered by replay history", async () => { - const src = eventFeed() - const ui = footer() - const gate = defer() - let transport: Awaited> | undefined - const task = createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - await gate.promise - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [textPart("text-1", "msg-1", "Hello")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.resolve() - src.push(textDelta("msg-1", "text-1", "lo")) - gate.resolve() - transport = await task - - await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined)) - await Bun.sleep(20) - expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([ - expect.objectContaining({ - text: "Hello", - }), - ]) - } finally { - src.close() - await transport?.close() - } - }) - - test("applies buffered pre-bootstrap deltas not yet persisted", async () => { - const src = eventFeed() - const ui = footer() - const gate = defer() - let transport: Awaited> | undefined - const task = createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - await gate.promise - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [textPart("text-1", "msg-1", "")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.resolve() - src.push(textDelta("msg-1", "text-1", "Hello")) - gate.resolve() - transport = await task - - await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined)) - await Bun.sleep(20) - expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([ - expect.objectContaining({ - text: "Hello", - }), - ]) - } finally { - src.close() - await transport?.close() - } - }) - - test("preserves running footer state for resumed active sessions", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - sessionID === "session-1" - ? ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "bash-1", - callID: "call-1", - tool: "bash", - body: { - command: "pwd", - }, - }), - ], - }), - ]) - : ok([]), - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const patch = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.patch") - return item?.type === "stream.patch" ? item.patch : undefined - }) - - expect(patch).toEqual( - expect.objectContaining({ - phase: "running", - status: "running bash", - }), - ) - } finally { - src.close() - await transport.close() - } - }) - - test("rebuilds session output on resize and continues live deltas from replayed state", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [textPart("text-1", "msg-1", "Hello")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const localRows: LocalReplayRow[] = [ - { commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } }, - ] - const reset = mock(() => { - localRows.push({ - commit: { - kind: "user", - text: "sent during reset", - phase: "start", - source: "system", - messageID: "msg-during-reset", - }, - }) - return Promise.resolve() - }) - - try { - expect( - await transport.replayOnResize({ - localRows: () => localRows, - reset, - }), - ).toBe(true) - expect(reset).toHaveBeenCalledTimes(1) - expect(ui.commits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "assistant", text: "Hello" }), - expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }), - ]), - ) - - src.push(textUpdated(textPart("text-1", "msg-1", "Hello world"))) - await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world")) - expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([ - "Hello", - " world", - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("coalesces active resize requests into one trailing replay", async () => { - const src = eventFeed() - const ui = footer() - const firstReset = defer() - const resetA = mock(() => firstReset.promise) - const resetB = mock(() => Promise.resolve()) - const resetC = mock(() => Promise.resolve()) - const transport = await createSessionTransport({ - sdk: sdk({ stream: src.stream }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const active = transport.replayOnResize({ localRows: () => [], reset: resetA }) - await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined)) - - expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false) - expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false) - expect(resetB).not.toHaveBeenCalled() - - firstReset.resolve() - expect(await active).toBe(true) - expect(resetA).toHaveBeenCalledTimes(1) - expect(resetB).not.toHaveBeenCalled() - expect(resetC).toHaveBeenCalledTimes(1) - } finally { - src.close() - await transport.close() - } - }) - - test("keeps coalescing resize requests while buffered events drain", async () => { - const src = eventFeed() - const ui = footer() - const firstReset = defer() - const statusGate = defer() - const statusStarted = defer() - let blockStatus = false - const trace = mock((_type: string, _data?: unknown) => {}) - const resetA = mock(() => firstReset.promise) - const resetB = mock(() => Promise.resolve()) - const resetC = mock(() => Promise.resolve()) - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - status: async () => { - if (blockStatus) { - statusStarted.resolve() - await statusGate.promise - } - return ok(statusMap(true)) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - trace: { write: trace }, - }) - const turn = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "active", parts: [] }, - files: [], - includeFiles: false, - }) - - try { - await waitFor(() => ui.events.find((event) => event.type === "turn.wait")) - const active = transport.replayOnResize({ localRows: () => [], reset: resetA }) - await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined)) - blockStatus = true - src.push(busy()) - src.push(idle()) - await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined)) - - expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false) - firstReset.resolve() - await Promise.race([ - statusStarted.promise, - Bun.sleep(1_000).then(() => { - throw new Error("timed out waiting for buffered status drain") - }), - ]) - - expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false) - expect(resetC).not.toHaveBeenCalled() - blockStatus = false - statusGate.resolve() - - expect( - await Promise.race([ - active, - Bun.sleep(1_000).then(() => { - throw new Error("timed out waiting for trailing resize replay") - }), - ]), - ).toBe(true) - expect(resetB).not.toHaveBeenCalled() - expect(resetC).toHaveBeenCalledTimes(1) - } finally { - src.close() - await transport.close() - await turn - } - }) - - test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-live", - parts: [textPart("text-live", "msg-live", "")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - src.push(assistant("msg-live")) - src.push(textUpdated(textPart("text-live", "msg-live", ""))) - src.push(textDelta("msg-live", "text-live", "Hello")) - await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello")) - ui.commits.length = 0 - - expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) - src.push(textDelta("msg-live", "text-live", "Hello")) - src.push( - textUpdated({ - ...textPart("text-live", "msg-live", "HelloHello"), - time: { start: 1, end: 2 }, - }), - ) - - await waitFor(() => - ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2 - ? true - : undefined, - ) - expect( - ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text), - ).toEqual(["Hello", "Hello"]) - } finally { - src.close() - await transport.close() - } - }) - - test("preserves the display prefix for active reasoning restored during replay", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-thinking", - parts: [reasoningPart("thinking-1", "msg-thinking", "")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - src.push(assistant("msg-thinking")) - src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", ""))) - src.push(textDelta("msg-thinking", "thinking-1", "plan")) - await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan")) - ui.commits.length = 0 - - expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) - expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([ - "Thinking: plan", - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("does not overlay stale active text when persistence completes during replay", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-finished", - parts: [ - { - ...textPart("text-finished", "msg-finished", "Hello"), - time: { start: 1, end: 2 }, - }, - ], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - src.push(assistant("msg-finished")) - src.push(textUpdated(textPart("text-finished", "msg-finished", ""))) - src.push(textDelta("msg-finished", "text-finished", "Hello")) - await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello")) - ui.commits.length = 0 - - expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) - expect( - ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text), - ).toEqual(["Hello"]) - } finally { - src.close() - await transport.close() - } - }) - - test("does not clear the terminal when resize replay snapshot fetch fails", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - throw new Error("snapshot failed") - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const reset = mock(() => Promise.resolve()) - - try { - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(reset).not.toHaveBeenCalled() - expect(ui.commits).toEqual([]) - } finally { - src.close() - await transport.close() - } - }) - - test("disables resize replay for the session after terminal reset fails", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ stream: src.stream }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const reset = mock(() => Promise.reject(new Error("clear failed"))) - - try { - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(reset).toHaveBeenCalledTimes(1) - expect(ui.commits).toContainEqual({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - } finally { - src.close() - await transport.close() - } - }) - - test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => { - const src = eventFeed() - const ui = footer() - let cleared = false - const idle = ui.api.idle - ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle()) - const transport = await createSessionTransport({ - sdk: sdk({ stream: src.stream }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const reset = mock(() => { - cleared = true - return Promise.resolve() - }) - - try { - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(reset).toHaveBeenCalledTimes(1) - expect(ui.commits).toContainEqual({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - } finally { - src.close() - await transport.close() - } - }) - - test("keeps completed historical subagent tabs during bootstrap", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - completedTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run folder", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - }, - children: async () => ok([child("child-1")]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const state = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" ? item.state : undefined - }) - - expect(state.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "completed" })]) - expect(state.details).toEqual({}) - } finally { - src.close() - await transport.close() - } - }) - - test("bootstraps child tabs and resumed blocker input", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID === "session-1") { - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run folder", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - } - - return ok([ - assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [ - runningTool({ - sessionID: "child-1", - messageID: "msg-child-1", - id: "edit-1", - callID: "call-edit-1", - tool: "edit", - body: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }), - ], - }), - ]) - }, - children: async () => ok([child("child-1")]), - permissions: async () => - ok([ - { - id: "perm-1", - sessionID: "child-1", - permission: "edit", - patterns: ["src/run/subagent-data.ts"], - metadata: {}, - always: [], - tool: { - messageID: "msg-child-1", - callID: "call-edit-1", - }, - }, - ]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const boot = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const state = item?.type === "stream.subagent" ? item.state : undefined - return state?.tabs.some((tab) => tab.sessionID === "child-1") && - state.permissions.some((req) => req.id === "perm-1") - ? state - : undefined - }) - - expect(boot.tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - label: "Explore", - description: "Pending permission", - status: "running", - }), - ]) - expect(boot.permissions).toEqual([ - expect.objectContaining({ - id: "perm-1", - sessionID: "child-1", - metadata: { - input: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }, - }), - ]) - - transport.selectSubagent("child-1") - - const selected = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const state = item?.type === "stream.subagent" ? item.state : undefined - const detail = state?.details["child-1"] - return detail?.commits.some( - (commit) => commit.kind === "tool" && commit.tool === "edit" && commit.phase === "start", - ) - ? state - : undefined - }) - - expect(selected.details).toEqual({ - "child-1": { - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "tool", - tool: "edit", - phase: "start", - }), - ], - }, - }) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.view") - return item?.type === "stream.view" && item.view.type === "permission" && item.view.request.id === "perm-1" - ? item - : undefined - }), - ).toEqual({ - type: "stream.view", - view: { - type: "permission", - request: expect.objectContaining({ - id: "perm-1", - metadata: { - input: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }, - }), - }, - }) - } finally { - src.close() - await transport.close() - } - }) - - test("bootstraps child session output before selection", async () => { - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - messages: async ({ sessionID }) => { - if (sessionID === "session-1") { - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - } - - return sessionID === "child-1" - ? ok([ - assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [textPart("txt-child-1", "msg-child-1", "subagent summary", "child-1")], - }), - ]) - : ok([]) - }, - children: async () => ok([child("child-1")]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item - : undefined - }) - - transport.selectSubagent("child-1") - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "subagent summary") - ? detail - : undefined - }), - ).toEqual({ - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "assistant", - text: "subagent summary", - }), - ], - }) - } finally { - await transport.close() - } - }) - - test("does not block startup on child history bootstrap", async () => { - const pending = defer>>>() - const ui = footer() - let transport: Awaited> | undefined - - const task = createSessionTransport({ - sdk: sdk({ - messages: async ({ sessionID }) => { - if (sessionID === "session-1") { - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - } - - if (sessionID === "child-1") { - return pending.promise - } - - return ok([]) - }, - children: async () => ok([child("child-1")]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }).then((item) => { - transport = item - return item - }) - - try { - const state = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item.state - : undefined - }) - - await waitFor(() => transport) - - expect(state).toEqual({ - tabs: [expect.objectContaining({ sessionID: "child-1", status: "running" })], - details: {}, - permissions: [], - questions: [], - }) - } finally { - pending.resolve(ok([])) - await task - await transport?.close() - } - }) - - test("replays child events buffered during bootstrap once the tab is known", async () => { - const global = globalFeed() - const ui = footer() - const gate = defer() - let transport: Awaited> | undefined - const task = createSessionTransport({ - sdk: sdk({ - globalStream: global.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - await gate.promise - return ok([]) - }, - children: async () => ok([]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.resolve() - global.push(globalEvent(retry("child-1", 1, "retry child"))) - global.push( - globalEvent({ - id: "evt-child-message", - type: "message.updated", - properties: { - sessionID: "child-1", - info: assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [], - }).info, - }, - }), - ) - global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "", "child-1")))) - global.push(globalEvent(textDelta("msg-child-1", "txt-child-1", "Hello", "child-1"))) - global.push( - globalEvent( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ), - ), - ) - gate.resolve() - transport = await task - - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item - : undefined - }) - - transport.selectSubagent("child-1") - - const detail = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const next = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return next?.commits.some((commit) => commit.kind === "error" && commit.text === "retry child") && - next.commits.some((commit) => commit.kind === "assistant" && commit.text === "Hello") - ? next - : undefined - }) - - expect(detail).toEqual({ - sessionID: "child-1", - commits: expect.arrayContaining([ - expect.objectContaining({ - kind: "error", - text: "retry child", - }), - expect.objectContaining({ - kind: "assistant", - text: "Hello", - }), - ]), - }) - } finally { - global.close() - await transport?.close() - } - }) - - test("streams selected subagent output from global events while it is running", async () => { - const global = globalFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - globalStream: global.stream, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - global.push(globalEvent(assistant("msg-1"))) - global.push( - globalEvent( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ), - ), - ) - - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item - : undefined - }) - - transport.selectSubagent("child-1") - - global.push( - globalEvent({ - id: "evt-child-message", - type: "message.updated", - properties: { - sessionID: "child-1", - info: assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [], - }).info, - }, - }), - ) - global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello", "child-1")))) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello") - ? detail - : undefined - }), - ).toEqual({ - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "assistant", - text: "hello", - }), - ], - }) - - global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello world", "child-1")))) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello world") - ? detail - : undefined - }, 2_000), - ).toEqual({ - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "assistant", - text: "hello world", - }), - ], - }) - } finally { - global.close() - await transport.close() - } - }) - - test("recovers pending questions from question.list when question.asked is missed", async () => { - const src = eventFeed() - const ui = footer() - let questionCalls = 0 - const request = { - id: "question-1", - sessionID: "session-1", - questions: [ - { - question: "Which area should I inspect first?", - header: "Area", - options: [{ label: "CLI", description: "Look at the direct run flow." }], - multiple: false, - }, - ], - tool: { - messageID: "msg-1", - callID: "call-question-1", - }, - } - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - questions: async () => { - questionCalls += 1 - return ok(questionCalls > 1 ? [request] : []) - }, - promptAsync: async () => { - queueMicrotask(() => { - src.push(busy()) - src.push(assistant("msg-1")) - src.push( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-tool-1", - callID: "call-question-1", - tool: "question", - body: { - questions: request.questions, - }, - }), - ), - ) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const run = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - const view = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.view") - return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined - }) - - expect(view).toEqual({ - type: "question", - request, - }) - - expect(ui.events).toContainEqual({ - type: "stream.patch", - patch: { - phase: "running", - status: "awaiting answer", - }, - }) - - src.push( - toolUpdated( - completedTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-tool-1", - callID: "call-question-1", - tool: "question", - body: { - questions: request.questions, - }, - output: "User has answered your questions.", - metadata: { - answers: [["CLI"]], - }, - }), - ), - ) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.view") - return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined - }), - ).toEqual({ - type: "stream.view", - view: { type: "prompt" }, - }) - - ctrl.abort() - await run - } finally { - src.close() - await transport.close() - } - }) - - test("does not resurrect questions if question.list resolves after tool completion", async () => { - const src = eventFeed() - const ui = footer() - const started = defer() - const request = { - id: "question-race-1", - sessionID: "session-1", - questions: [ - { - question: "Which area should I inspect first?", - header: "Area", - options: [{ label: "CLI", description: "Look at the direct run flow." }], - multiple: false, - }, - ], - tool: { - messageID: "msg-1", - callID: "call-question-race-1", - }, - } - const pending = defer>>>() - let questionCalls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - questions: async () => { - questionCalls += 1 - if (questionCalls === 1) { - return ok([]) - } - - if (questionCalls === 2) { - started.resolve() - return pending.promise - } - - return ok([]) - }, - promptAsync: async () => { - queueMicrotask(() => { - src.push(busy()) - src.push(assistant("msg-1")) - src.push( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-race-tool-1", - callID: "call-question-race-1", - tool: "question", - body: { - questions: request.questions, - }, - }), - ), - ) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const run = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - await started.promise - src.push( - toolUpdated( - completedTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-race-tool-1", - callID: "call-question-race-1", - tool: "question", - body: { - questions: request.questions, - }, - output: "User has answered your questions.", - metadata: { - answers: [["CLI"]], - }, - }), - ), - ) - await waitFor(() => { - const commit = ui.commits.findLast( - (item) => item.kind === "tool" && item.partID === "question-race-tool-1" && item.toolState === "completed", - ) - return commit ? true : undefined - }) - pending.resolve(ok([request])) - - await Bun.sleep(50) - - expect( - ui.events.some( - (event) => - event.type === "stream.view" && event.view.type === "question" && event.view.request.id === request.id, - ), - ).toBe(false) - - ctrl.abort() - await run - } finally { - src.close() - await transport.close() - } - }) - - test("respects the includeFiles flag when building prompt payloads", async () => { - const src = eventFeed() - const ui = footer() - const seen: unknown[] = [] - const file: RunFilePart = { - type: "file", - url: "file:///tmp/a.ts", - filename: "a.ts", - mime: "text/plain", - } - - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async (input) => { - seen.push(input) - queueMicrotask(() => { - src.push(busy()) - src.push(idle()) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [file], - includeFiles: true, - }) - - await transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "again", parts: [] }, - files: [file], - includeFiles: false, - }) - - expect(seen).toEqual([ - expect.objectContaining({ - parts: [file, { type: "text", text: "hello" }], - }), - expect.objectContaining({ - parts: [{ type: "text", text: "again" }], - }), - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("falls back to session status polling when idle events are missing", async () => { - const src = eventFeed() - const ui = footer() - let busy = true - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async () => { - queueMicrotask(() => { - src.push(assistant("msg-1")) - busy = false - }) - return ok(undefined) - }, - status: async () => ok(statusMap(busy)), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.race([ - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }), - new Promise((_, reject) => setTimeout(() => reject(new Error("turn timed out")), 1_000)), - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("flushes interrupted output when the active turn aborts", async () => { - const src = eventFeed() - const seen = defer() - const ui = footer((commit) => { - if (commit.kind === "assistant" && commit.phase === "progress") { - seen.resolve() - } - }) - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async () => { - queueMicrotask(() => { - src.push(busy()) - src.push(assistant("msg-1")) - src.push(textUpdated(textPart("txt-1", "msg-1", ""))) - src.push(textDelta("msg-1", "txt-1", "unfinished")) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const task = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - await seen.promise - ctrl.abort() - await task - - expect(ui.commits).toEqual([ - { - kind: "assistant", - text: "unfinished", - phase: "progress", - source: "assistant", - messageID: "msg-1", - partID: "txt-1", - }, - { - kind: "assistant", - text: "", - phase: "final", - source: "assistant", - messageID: "msg-1", - partID: "txt-1", - interrupted: true, - }, - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("closes an active turn without rejecting it", async () => { - const src = eventFeed() - const ui = footer() - const ready = defer() - let aborted = false - - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async (_input, opt) => { - ready.resolve() - await new Promise((resolve) => { - const onAbort = () => { - aborted = true - opt?.signal?.removeEventListener("abort", onAbort) - resolve() - } - - opt?.signal?.addEventListener("abort", onAbort, { once: true }) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const task = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }) - - await ready.promise - await transport.close() - await task - - expect(aborted).toBe(true) - } finally { - src.close() - await transport.close() - } - }) - - test("rejects the active turn when the event stream faults", async () => { - const ui = footer() - const ready = defer() - - const transport = await createSessionTransport({ - sdk: sdk({ - globalEvent: () => - globalSse( - (async function* (): AsyncGenerator { - await ready.promise - yield globalEvent(busy()) - throw new Error("boom") - })(), - ), - promptAsync: async () => { - ready.resolve() - return ok(undefined) - }, - status: async () => ok({ "session-1": { type: "busy" } }), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await expect( - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }), - ).rejects.toThrow("boom") - } finally { - await transport.close() - } - }) - - test("rejects the active turn when the backing instance is disposed", async () => { - const ui = footer() - const ready = defer() - - const transport = await createSessionTransport({ - sdk: sdk({ - globalEvent: () => - globalSse( - (async function* (): AsyncGenerator { - await ready.promise - yield globalEvent({ - id: "evt-disposed", - type: "server.instance.disposed", - properties: { - directory: "/tmp", - }, - }) - })(), - ), - promptAsync: async () => { - ready.resolve() - return ok(undefined) - }, - status: async () => ok({}), - }), - directory: "/tmp", - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await expect( - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }), - ).rejects.toThrow("instance disposed") - } finally { - await transport.close() - } - }) - - test("rejects concurrent turns", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const task = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "one", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - await expect( - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "two", parts: [] }, - files: [], - includeFiles: false, - }), - ).rejects.toThrow("prompt already running") - - ctrl.abort() - await task - } finally { - src.close() - await transport.close() - } - }) -}) diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts deleted file mode 100644 index 4dcbd09608..0000000000 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Event } from "@opencode-ai/sdk/v2" -import { entryBody } from "@/cli/cmd/run/entry.body" -import { - bootstrapSubagentCalls, - bootstrapSubagentData, - createSubagentData, - reduceSubagentData, - snapshotSubagentData, -} from "@/cli/cmd/run/subagent-data" - -type SessionMessage = Parameters[0]["messages"][number] -type ChildMessage = Parameters[0]["messages"][number] - -function visible(commits: Array[0]>) { - return commits.flatMap((item) => { - const body = entryBody(item) - if (body.type === "none") { - return [] - } - - if (body.type === "structured") { - if (body.snapshot.kind === "code" || body.snapshot.kind === "task") { - return [body.snapshot.title] - } - - if (body.snapshot.kind === "diff") { - return body.snapshot.items.map((item) => item.title) - } - - if (body.snapshot.kind === "todo") { - return ["# Todos"] - } - - return ["# Questions"] - } - - return [body.content] - }) -} - -function reduce(data: ReturnType, event: unknown) { - return reduceSubagentData({ - data, - event: event as Event, - sessionID: "parent-1", - thinking: true, - limits: {}, - }) -} - -function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage { - if (status === "running") { - return { - parts: [ - { - id: `part-${sessionID}`, - sessionID: "parent-1", - messageID: `msg-${sessionID}`, - type: "tool", - callID: `call-${sessionID}`, - tool: "task", - state: { - status: "running", - input: { - description: "Scan reducer paths", - subagent_type: "explore", - }, - title: "Reducer touchpoints", - metadata: { - sessionId: sessionID, - toolcalls: 4, - }, - time: { start: 1 }, - }, - }, - ], - } - } - - if (status === "interrupted") { - return { - parts: [ - { - id: `part-${sessionID}`, - sessionID: "parent-1", - messageID: `msg-${sessionID}`, - type: "tool", - callID: `call-${sessionID}`, - tool: "task", - state: { - status: "error", - input: { - description: "Scan reducer paths", - subagent_type: "explore", - }, - error: "Tool execution aborted", - metadata: { - sessionId: sessionID, - toolcalls: 4, - interrupted: true, - }, - time: { start: 1, end: 2 }, - }, - }, - ], - } - } - - return { - parts: [ - { - id: `part-${sessionID}`, - sessionID: "parent-1", - messageID: `msg-${sessionID}`, - type: "tool", - callID: `call-${sessionID}`, - tool: "task", - state: { - status: "completed", - input: { - description: "Scan reducer paths", - subagent_type: "explore", - }, - output: "", - title: "Reducer touchpoints", - metadata: { - sessionId: sessionID, - toolcalls: 4, - }, - time: { start: 1, end: 2 }, - }, - }, - ], - } -} - -function question(id: string, sessionID: string) { - return { - id, - sessionID, - questions: [ - { - question: "Mode?", - header: "Mode", - options: [{ label: "Fast", description: "Quick pass" }], - multiple: false, - }, - ], - } -} - -function childMessage(input: { - messageID: string - sessionID: string - role: "user" | "assistant" - parts: ChildMessage["parts"] -}) { - if (input.role === "user") { - return { - info: { - id: input.messageID, - sessionID: input.sessionID, - role: "user", - time: { - created: 1, - }, - agent: "test", - model: { - providerID: "openai", - modelID: "gpt-5", - }, - }, - parts: input.parts, - } satisfies ChildMessage - } - - return { - info: { - id: input.messageID, - sessionID: input.sessionID, - role: "assistant", - time: { - created: 2, - completed: 3, - }, - parentID: "msg-user-1", - providerID: "openai", - modelID: "gpt-5", - mode: "default", - agent: "explore", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - finish: "stop", - }, - parts: input.parts, - } satisfies ChildMessage -} - -describe("run subagent data", () => { - test("bootstraps tabs and child blockers from parent task parts", () => { - const data = createSubagentData() - - expect( - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1")], - children: [{ id: "child-1" }, { id: "child-2" }], - permissions: [ - { - id: "perm-1", - sessionID: "child-1", - permission: "read", - patterns: ["src/**/*.ts"], - metadata: {}, - always: [], - }, - { - id: "perm-2", - sessionID: "other", - permission: "read", - patterns: ["src/**/*.ts"], - metadata: {}, - always: [], - }, - ], - questions: [question("question-1", "child-1"), question("question-2", "other")], - }), - ).toBe(true) - - const snapshot = snapshotSubagentData(data) - - expect(snapshot.tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - label: "Explore", - description: "Scan reducer paths", - title: "Reducer touchpoints", - status: "completed", - toolCalls: 4, - }), - ]) - expect(snapshot.details).toEqual({ - "child-1": { - sessionID: "child-1", - commits: [], - }, - }) - expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"]) - expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"]) - }) - - test("marks interrupted task tabs as cancelled during bootstrap", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "interrupted")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - expect(snapshotSubagentData(data).tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - status: "cancelled", - }), - ]) - }) - - test("captures child activity and blocker metadata in the footer detail state", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "running")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "txt-user-1", - messageID: "msg-user-1", - sessionID: "child-1", - type: "text", - text: "Inspect footer tabs", - }, - }, - }) - reduce(data, { - type: "message.updated", - properties: { - sessionID: "child-1", - info: { - id: "msg-user-1", - role: "user", - }, - }, - }) - reduce(data, { - type: "message.updated", - properties: { - sessionID: "child-1", - info: { - id: "msg-assistant-1", - role: "assistant", - }, - }, - }) - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "reason-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "reasoning", - text: "planning next steps", - time: { start: 1 }, - }, - }, - }) - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "tool-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "tool", - callID: "call-1", - tool: "bash", - state: { - status: "running", - input: { - command: "git status --short", - }, - time: { start: 1 }, - }, - }, - }, - }) - reduce(data, { - type: "permission.asked", - properties: { - id: "perm-1", - sessionID: "child-1", - permission: "bash", - patterns: ["git status --short"], - metadata: {}, - always: [], - tool: { - messageID: "msg-assistant-1", - callID: "call-1", - }, - }, - }) - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "txt-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "text", - text: "hello", - }, - }, - }) - reduce(data, { - type: "message.part.delta", - properties: { - sessionID: "child-1", - messageID: "msg-assistant-1", - partID: "txt-1", - field: "text", - delta: " world", - }, - }) - - const snapshot = snapshotSubagentData(data) - - expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })]) - expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([ - "› Inspect footer tabs", - "_Thinking:_ planning next steps", - "$ git status --short", - "hello world", - ]) - expect(snapshot.permissions).toEqual([ - expect.objectContaining({ - id: "perm-1", - metadata: { - input: { - command: "git status --short", - }, - }, - }), - ]) - expect(snapshot.questions).toEqual([]) - }) - - test("replays bootstrapped child session messages into inspector commits", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "completed")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - expect( - bootstrapSubagentCalls({ - data, - sessionID: "child-1", - messages: [ - childMessage({ - messageID: "msg-user-1", - sessionID: "child-1", - role: "user", - parts: [ - { - id: "txt-user-1", - messageID: "msg-user-1", - sessionID: "child-1", - type: "text", - text: "Inspect footer tabs", - time: { start: 1, end: 1 }, - }, - ], - }), - childMessage({ - messageID: "msg-assistant-1", - sessionID: "child-1", - role: "assistant", - parts: [ - { - id: "reason-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "reasoning", - text: "planning next steps", - time: { start: 2, end: 2 }, - }, - { - id: "txt-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "text", - text: "hello world", - time: { start: 2, end: 3 }, - }, - ], - }), - ], - thinking: true, - limits: {}, - }), - ).toBe(true) - - expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([ - "› Inspect footer tabs", - "_Thinking:_ planning next steps", - "hello world", - ]) - }) - - test("marks a running tab cancelled when the child session aborts", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "running")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - reduce(data, { - type: "message.updated", - properties: { - sessionID: "child-1", - info: { - id: "msg-assistant-1", - sessionID: "child-1", - role: "assistant", - time: { - created: 1, - completed: 2, - }, - error: { - name: "MessageAbortedError", - data: { - message: "Aborted", - }, - }, - parentID: "msg-user-1", - providerID: "openai", - modelID: "gpt-5", - mode: "default", - agent: "explore", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - finish: "error", - }, - }, - }) - - expect(snapshotSubagentData(data).tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - status: "cancelled", - }), - ]) - }) -}) diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index 3de324b5e4..e05c9888bb 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -1,9 +1,8 @@ import path from "path" -import { NodeFileSystem } from "@effect/platform-node" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util" import { describe, expect, test } from "bun:test" -import { Effect, FileSystem, Layer } from "effect" +import { Effect, Layer } from "effect" import { Global } from "@opencode-ai/core/global" import { createVariantRuntime, @@ -99,7 +98,7 @@ function userMessage( } } -const it = testEffect(Layer.mergeAll(LayerNode.compile(FSUtil.node), NodeFileSystem.layer)) +const it = testEffect(AppNodeBuilder.build(FSUtil.node)) function remap(root: string, file: string) { if (file === Global.Path.state) { @@ -124,7 +123,7 @@ function remappedFs(root: string) { writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode), }) }), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + ).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) } describe("run variant shared", () => { @@ -160,9 +159,8 @@ describe("run variant shared", () => { it.live("reads and writes saved variants through a runtime-backed app fs layer", () => Effect.gen(function* () { - const filesys = yield* FileSystem.FileSystem const fs = yield* FSUtil.Service - const root = yield* filesys.makeTempDirectoryScoped() + const root = yield* fs.makeTempDirectoryScoped() const file = path.join(root, "model.json") yield* fs.writeJson(file, { @@ -172,7 +170,7 @@ describe("run variant shared", () => { }, }) - const svc = createVariantRuntime(remappedFs(root)) + const svc = createVariantRuntime([[FSUtil.node, remappedFs(root)]]) yield* Effect.promise(() => svc.saveVariant(model, "high")) expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") @@ -197,14 +195,13 @@ describe("run variant shared", () => { it.live("repairs malformed saved variant state on the next write", () => Effect.gen(function* () { - const filesys = yield* FileSystem.FileSystem const fs = yield* FSUtil.Service - const root = yield* filesys.makeTempDirectoryScoped() + const root = yield* fs.makeTempDirectoryScoped() const file = path.join(root, "model.json") - yield* filesys.writeFileString(file, "{") + yield* fs.writeFileString(file, "{") - const svc = createVariantRuntime(remappedFs(root)) + const svc = createVariantRuntime([[FSUtil.node, remappedFs(root)]]) yield* Effect.promise(() => svc.saveVariant(model, "high")) expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 73f87d904b..a358eddb31 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import path from "path" import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" +import { MiniLocalCommand } from "../../../src/cli/cmd/mini" import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" import { cliIt } from "../../lib/cli-process" @@ -45,12 +46,12 @@ describe("tui thread", () => { expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) }) - test("parses supported --no-replay forms", async () => { + test("parses supported mini --no-replay forms", async () => { for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { const args = await yargs([]) - .command({ ...TuiThreadCommand, handler: () => {} }) + .command({ ...MiniLocalCommand, handler: () => {} }) .exitProcess(false) - .parse(["--mini", option, "--replay-limit", "10"]) + .parse([option, "--replay-limit", "10"]) expect(args.replay === false || args.noReplay === true).toBe(true) expect(args.replayLimit).toBe(10) @@ -66,30 +67,48 @@ describe("tui thread", () => { expect(args.mdns).toBe(false) }) - cliIt.live("rejects mini-only options without --mini", ({ opencode }) => + cliIt.live("rejects removed top-level mini alias", ({ opencode }) => Effect.gen(function* () { - const result = yield* opencode.spawn(["--replay-limit", "10"]) + const result = yield* opencode.spawn(["--mini"]) opencode.expectExit(result, 1) - expect(result.stderr).toContain("--replay-limit requires --mini") + expect(result.stderr).not.toContain("opencode mini requires a TTY stdout") }), ) - cliIt.live("routes attached sessions to mini mode", ({ opencode }) => + cliIt.live("rejects removed run mini flag", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["run", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).not.toContain("opencode mini requires a TTY stdout") + }), + ) + + cliIt.live("routes local sessions through mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("opencode mini requires a TTY stdout") + }), + ) + + cliIt.live("routes attached sessions through mini attach", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("opencode mini requires a TTY stdout") + }), + ) + + cliIt.live("rejects removed attach mini alias", ({ opencode }) => Effect.gen(function* () { const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) opencode.expectExit(result, 1) - expect(result.stderr).toContain("--mini requires a TTY stdout") - }), - ) - - cliIt.live("rejects network options in mini mode", ({ opencode }) => - Effect.gen(function* () { - const result = yield* opencode.spawn(["--mini", "--port", "4096"]) - - opencode.expectExit(result, 1) - expect(result.stderr).toContain("--port cannot be used with --mini") + expect(result.stderr).not.toContain("opencode mini requires a TTY stdout") }), ) }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 12e8d9c866..77684d5e4f 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -5,8 +5,7 @@ // argv parsing → server boot → SDK call → event consumption → exit code (like // the original /event race or #27371's invalid-model hang). // -// Configuration flows through opencode's built-in test affordances: -// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find +// Configuration flows through an isolated global opencode.json under the temp home: // - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir // - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json // - OPENCODE_PURE : skip external plugin discovery + install @@ -59,15 +58,16 @@ function forkStderrDrain(stream: ReadableStream, into: string[]) { ) } -function isolatedEnv(home: string, configJson: string): Record { +function isolatedEnv(home: string): Record { return { OPENCODE_TEST_HOME: home, + PWD: home, HOME: home, XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(home, ".local/share"), XDG_STATE_HOME: path.join(home, ".local/state"), XDG_CACHE_HOME: path.join(home, ".cache"), - OPENCODE_CONFIG_CONTENT: configJson, + OPENCODE_CONFIG_DIR: path.join(home, ".opencode-config"), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_PURE: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", @@ -89,7 +89,11 @@ export type RunHandle = { readonly result: Effect.Effect } -export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record } +export type SpawnOpts = { + readonly timeoutMs?: number + readonly env?: Record + readonly config?: ReturnType & Record +} // Typed equivalent of constructing argv for `opencode run`. New flags should // land here so tests stay grep-able and refactor-safe. @@ -201,10 +205,15 @@ export function withCliFixture( .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), ) - const configJson = JSON.stringify(testProviderConfig(llm.url)) - const env = isolatedEnv(home, configJson) + const env = isolatedEnv(home) + const writeConfig = (config?: SpawnOpts["config"]) => + fs + .writeWithDirs(path.join(env.OPENCODE_CONFIG_DIR, "opencode.json"), JSON.stringify(config ?? testProviderConfig(llm.url))) + .pipe(Effect.orDie) + yield* writeConfig() const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { + yield* writeConfig(opts?.config) const start = Date.now() const timeoutMs = opts?.timeoutMs ?? 30_000 // stdin: "ignore" so the child doesn't see a piped stdin and block @@ -212,7 +221,7 @@ export function withCliFixture( // consumed as the prompt). The old Process.run wrapper defaulted to // ignore; ChildProcess.make defaults to pipe, so we set it explicitly. const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], { - cwd: home, + cwd: opencodeRoot, env: { ...env, ...opts?.env }, extendEnv: true, stdin: "ignore", @@ -250,6 +259,7 @@ export function withCliFixture( const runArgs = (message: string, opts?: RunOpts) => { const argv: string[] = ["run"] + if (!opts?.extraArgs?.includes("--attach")) argv.push("--dir", home) if (opts?.printLogs) argv.push("--print-logs") argv.push("--model", opts?.model ?? testModelID) if (opts?.agent) argv.push("--agent", opts.agent) @@ -264,13 +274,8 @@ export function withCliFixture( if (!opts?.permission) return opts return { ...opts, - env: { - ...opts.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify({ - ...testProviderConfig(llm.url), - permission: opts.permission, - }), - }, + env: opts.env, + config: { ...testProviderConfig(llm.url), permission: opts.permission }, } } @@ -281,10 +286,11 @@ export function withCliFixture( const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) { const start = Date.now() const options = runOpts(opts) + yield* writeConfig(options?.config) const proc = yield* Effect.acquireRelease( Effect.sync(() => Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], { - cwd: home, + cwd: opencodeRoot, env: { ...process.env, ...env, ...options?.env }, stdin: "ignore", stdout: "pipe", @@ -325,7 +331,7 @@ export function withCliFixture( const proc = yield* Effect.acquireRelease( Effect.sync(() => Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { - cwd: home, + cwd: opencodeRoot, env: { ...process.env, ...env, ...opts?.env }, stdout: "pipe", stderr: "pipe", @@ -396,7 +402,7 @@ export function withCliFixture( const proc = yield* Effect.acquireRelease( Effect.sync(() => Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { - cwd: opts?.cwd ?? home, + cwd: opencodeRoot, env: { ...process.env, ...env, ...opts?.env }, stdin: "pipe", stdout: "pipe", diff --git a/packages/opencode/test/lib/test-provider.ts b/packages/opencode/test/lib/test-provider.ts index cfb5a93e33..f886ea54b7 100644 --- a/packages/opencode/test/lib/test-provider.ts +++ b/packages/opencode/test/lib/test-provider.ts @@ -30,7 +30,7 @@ export function testProviderConfig(llmUrl: string) { options: {}, }, }, - options: { apiKey: "test-key", baseURL: llmUrl }, + options: { apiKey: "test-key", baseURL: llmUrl, body: { apiKey: "test-key" } }, }, }, } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index b8368383d0..2b768bc1d5 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -665,6 +665,8 @@ const scenarios: Scenario[] = [ http.protected.get("/api/location", "v2.location.get").json(200, object), http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)), http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)), + // The default model may be undefined in the exercise environment, so only the location envelope is asserted. + http.protected.get("/api/model/default", "v2.model.default").json(200, object), http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)), http.protected.get("/api/integration", "v2.integration.list").json(200, locationData(array)), http.protected diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 74b1eb671d..be7f081520 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -249,8 +249,14 @@ export type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"] export type Endpoint6_0Output = EffectValue> export type ModelListOperation = (input?: Endpoint6_0Input) => Effect.Effect +type Endpoint6_1Request = Parameters[0] +export type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] } +export type Endpoint6_1Output = EffectValue> +export type ModelDefaultOperation = (input?: Endpoint6_1Input) => Effect.Effect + export interface ModelApi { readonly list: ModelListOperation + readonly default: ModelDefaultOperation } type Endpoint7_0Request = Parameters[0] diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index c8333eafb5..071a1b4735 100644 --- a/packages/protocol/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -21,6 +21,21 @@ export const ModelGroup = HttpApiGroup.make("server.model") }), ), ) + .add( + HttpApiEndpoint.get("model.default", "/api/model/default", { + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Model.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.default", + summary: "Get default model", + description: "Retrieve the model used when a session has no explicit model selection.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "models", diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 5013dddee9..df78fa1493 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -119,6 +119,16 @@ export const PromptAdmitted = Event.define({ }) export type PromptAdmitted = typeof PromptAdmitted.Type +export const ExecutionSettled = Event.define({ + type: "session.next.execution.settled", + schema: { + ...Base, + outcome: Schema.Literals(["success", "failure", "interrupted"]), + error: UnknownError.pipe(optional), + }, +}) +export type ExecutionSettled = typeof ExecutionSettled.Type + export const ContextUpdated = Event.define({ type: "session.next.context.updated", ...options, @@ -524,6 +534,7 @@ export const Definitions = Event.inventory( Forked, Prompted, PromptAdmitted, + ExecutionSettled, ContextUpdated, Synthetic, Skill.Activated, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1a82fca91c..a2946a7fc5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -301,6 +301,8 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2ModelDefaultErrors, + V2ModelDefaultResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -6120,6 +6122,28 @@ export class Model extends HeyApiClient { ...params, }) } + + /** + * Get default model + * + * Retrieve the model used when a session has no explicit model selection. + */ + public default( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/model/default", + ...options, + ...params, + }) + } } export class Generate extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 82fe581d70..5a165e24cf 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -24,6 +24,7 @@ export type Event = | EventSessionNextForked | EventSessionNextPrompted | EventSessionNextPromptAdmitted + | EventSessionNextExecutionSettled | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextSkillActivated @@ -923,6 +924,16 @@ export type GlobalEvent = { delivery: "steer" | "queue" } } + | { + id: string + type: "session.next.execution.settled" + properties: { + timestamp: number + sessionID: string + outcome: "success" | "failure" | "interrupted" + error?: SessionErrorUnknown + } + } | { id: string type: "session.next.context.updated" @@ -3010,6 +3021,7 @@ export type V2Event = | SessionNextForked | SessionNextPrompted | SessionNextPromptAdmitted + | SessionNextExecutionSettled | SessionNextContextUpdated | SessionNextSynthetic | SessionNextSkillActivated @@ -5597,6 +5609,26 @@ export type MessagePartRemoved = { } } +export type SessionNextExecutionSettled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.execution.settled" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + outcome: "success" | "failure" | "interrupted" + error?: SessionErrorUnknown + } +} + export type SessionNextTextDelta = { id: string metadata?: { @@ -6802,6 +6834,17 @@ export type EventSessionNextPromptAdmitted = { } } +export type EventSessionNextExecutionSettled = { + id: string + type: "session.next.execution.settled" + properties: { + timestamp: number + sessionID: string + outcome: "success" | "failure" | "interrupted" + error?: SessionErrorUnknown + } +} + export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" @@ -12875,6 +12918,47 @@ export type V2ModelListResponses = { export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] +export type V2ModelDefaultData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/model/default" +} + +export type V2ModelDefaultErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ModelDefaultError = V2ModelDefaultErrors[keyof V2ModelDefaultErrors] + +export type V2ModelDefaultResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: ModelV2Info + } +} + +export type V2ModelDefaultResponse = V2ModelDefaultResponses[keyof V2ModelDefaultResponses] + export type V2GenerateTextData = { body: { prompt: string diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 36639ae7b1..5059804354 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -6,12 +6,20 @@ import { response } from "../location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { - return handlers.handle( - "model.list", - Effect.fn(function* () { - const catalog = yield* Catalog.Service - return yield* response(catalog.model.available()) - }), - ) + return handlers + .handle( + "model.list", + Effect.fn(function* () { + const catalog = yield* Catalog.Service + return yield* response(catalog.model.available()) + }), + ) + .handle( + "model.default", + Effect.fn(function* () { + const catalog = yield* Catalog.Service + return yield* response(catalog.model.default()) + }), + ) }), ) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index dec23ebae7..c0bf822532 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,19 @@ # V2 Schema Changelog +## 2026-07-02: Add Default Model Endpoint + +- Add `GET /api/model/default` (`v2.model.default`) returning the Location's resolved default model, or `undefined` when no model is available. +- Expose the existing core `Catalog.model.default()` resolution (configured default first, then availability heuristics) over HTTP with regenerated Promise, Effect, and legacy JavaScript client surfaces. + +Change: + +- Clients that need to pin a full model reference before prompt admission (for example `run --variant` without `--model` on a session with no model) previously had no current API for the default model and fell back to the legacy `/config` read or the first entry of `v2.model.list`, which can diverge from the runner's own default resolution. + +Compatibility: + +- Purely additive HTTP surface; no durable-event, projection, or database change. +- `v2.model.list` ordering and semantics are unchanged. + ## 2026-07-01: Synthetic Message Metadata And Model-Visible Leak Fix - Add optional `metadata: Record` to the durable `session.next.synthetic.1` event data so synthetic messages can carry a durable ledger (e.g. lazy-instruction dedup paths).