feat(mini): migrate mini to v2 (#34895)
feat(run): migrate non-interactive prompts to V2 feat(run): route mini prompts through V2 fix(run): use current session contracts fix(run): fix V2 prompt turns feat(cli): add mini subcommand feat(run): use settled execution events fix(run): handle remote prompt file attachments feat(run): send prompt files as attachments feat(run): use current APIs for run state fix(run): adopt app-node runtime deps feat(run): track subagent sessions feat(run): move catalogs and default model onto current APIs
This commit is contained in:
parent
140224b0fc
commit
f016392368
61 changed files with 5196 additions and 7098 deletions
|
|
@ -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<RawClient["server.model"]["model.default"]>[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<RawClient["server.generate"]["generate.text"]>[0]
|
||||
type Endpoint7_0Input = {
|
||||
|
|
|
|||
|
|
@ -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<ModelDefaultOutput>(
|
||||
{
|
||||
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) =>
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
readonly output: ReadonlyArray<string>
|
||||
}
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
172
packages/opencode/src/cli/cmd/mini.ts
Normal file
172
packages/opencode/src/cli/cmd/mini.ts
Normal file
|
|
@ -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<T>(yargs: Argv<T>) {
|
||||
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 <url>",
|
||||
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 () => {},
|
||||
})
|
||||
|
|
@ -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<OpencodeClient["session"]["prompt"]>[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<typeof Filesystem.stat>
|
||||
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<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await sdk.session
|
||||
.get({
|
||||
sessionID: args.session,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
|
||||
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<SessionInfo | undefined> {
|
||||
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<SessionInfo | undefined> {
|
||||
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<SessionInfo | undefined> {
|
||||
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<SessionInfo> {
|
||||
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<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
|
||||
}
|
||||
|
|
|
|||
114
packages/opencode/src/cli/cmd/run/catalog.shared.ts
Normal file
114
packages/opencode/src/cli/cmd/run/catalog.shared.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
|
||||
|
||||
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["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<string, RunProvider>()
|
||||
|
||||
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<RunAgent[]> {
|
||||
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<RunCommand[]> {
|
||||
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<RunReference[]> {
|
||||
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<RunProvider[]> {
|
||||
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 ?? [])
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
resources: Accessor<RunResource[]>
|
||||
references: Accessor<RunReference[]>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
tuiConfig: RunTuiConfig
|
||||
state: Accessor<FooterState>
|
||||
|
|
@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
},
|
||||
}))
|
||||
})
|
||||
const resources = createMemo<Auto[]>(() => {
|
||||
return input.resources().map((item) => ({
|
||||
const references = createMemo<Auto[]>(() => {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={interruptHint()}>
|
||||
{(hint) => (
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{hint()} interrupt
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.index()} of {props.total()}
|
||||
|
|
|
|||
|
|
@ -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<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
references: RunReference[]
|
||||
commands?: RunCommand[]
|
||||
wrote?: boolean
|
||||
sessionID: () => string | undefined
|
||||
|
|
@ -97,6 +97,7 @@ type RunFooterOptions = {
|
|||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
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<RunAgent[]>
|
||||
private setAgents: Setter<RunAgent[]>
|
||||
private resources: Accessor<RunResource[]>
|
||||
private setResources: Setter<RunResource[]>
|
||||
private references: Accessor<RunReference[]>
|
||||
private setReferences: Setter<RunReference[]>
|
||||
private commands: Accessor<RunCommand[] | undefined>
|
||||
private setCommands: Setter<RunCommand[] | undefined>
|
||||
private providers: Accessor<RunProvider[] | undefined>
|
||||
|
|
@ -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<RunCommand[] | undefined>(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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string[]>
|
||||
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<boolean>
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
|
|||
459
packages/opencode/src/cli/cmd/run/noninteractive.ts
Normal file
459
packages/opencode/src/cli/cmd/run/noninteractive.ts
Normal file
|
|
@ -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<void>
|
||||
renderToolError: (part: ToolPart) => Promise<void>
|
||||
}
|
||||
|
||||
type StartedPart = {
|
||||
id: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type ToolState = StartedPart & {
|
||||
assistantMessageID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
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<V2Event>
|
||||
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<string, StartedPart>()
|
||||
const tools = new Map<string, ToolState>()
|
||||
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<string, unknown>) => {
|
||||
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<void> | 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: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string[]>
|
||||
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<Lif
|
|||
directory: input.directory,
|
||||
findFiles: input.findFiles,
|
||||
agents: input.agents,
|
||||
resources: input.resources,
|
||||
references: input.references,
|
||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||
...labels,
|
||||
model: input.model,
|
||||
|
|
@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
}
|
||||
},
|
||||
onSubagentSelect: input.onSubagentSelect,
|
||||
onSubagentInterrupt: input.onSubagentInterrupt,
|
||||
})
|
||||
|
||||
const sigint = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>
|
||||
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
|
||||
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
|
||||
createSession?: CreateSession
|
||||
agent: RunInput["agent"]
|
||||
model: RunInput["model"]
|
||||
|
|
@ -77,7 +77,7 @@ type RunLocalInput = {
|
|||
}
|
||||
|
||||
type StreamTransportModule = Pick<
|
||||
Awaited<typeof import("./stream.transport")>,
|
||||
Awaited<typeof import("./stream-v2.transport")>,
|
||||
"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<voi
|
|||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
void input.share(sdk, next.id).catch(() => {})
|
||||
return {
|
||||
sessionID: next.id,
|
||||
sessionTitle: next.title,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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<string, number>
|
||||
providers?: RunProvider[]
|
||||
}
|
||||
|
||||
type ReplayConfig = {
|
||||
limits: Record<string, number>
|
||||
providers?: RunProvider[]
|
||||
summaries: ReadonlySet<string>
|
||||
}
|
||||
|
||||
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<string, number>) {
|
||||
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<string>) {
|
||||
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<string> {
|
||||
const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
|
||||
const parents = new Set<string>()
|
||||
const summaries = new Set<string>()
|
||||
|
||||
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[]
|
||||
})
|
||||
}
|
||||
|
|
@ -152,12 +152,52 @@ export function createSession(messages: SessionMessages): RunSession {
|
|||
}
|
||||
}
|
||||
|
||||
export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
|
||||
const response = await sdk.session.messages({
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
return createSession(response.data ?? [])
|
||||
export async function resolveCurrentSession(
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
limit = LIMIT,
|
||||
): Promise<RunSession> {
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
698
packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts
Normal file
698
packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
started: number
|
||||
}
|
||||
|
||||
type ChildState = {
|
||||
sessionID: string
|
||||
label: string
|
||||
description: string
|
||||
status: FooterSubagentTab["status"]
|
||||
background: boolean
|
||||
title?: string
|
||||
callIDs: Set<string>
|
||||
lastUpdatedAt: number
|
||||
frames: Frame[]
|
||||
text: Map<string, string>
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
tools: Map<string, ToolTrack>
|
||||
finishedTools: Set<string>
|
||||
messageIDs: Set<string>
|
||||
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<string, unknown> }): Promise<void>
|
||||
select(sessionID: string | undefined): void
|
||||
snapshot(): FooterSubagentState
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
|
||||
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<string, unknown> | 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<string, ChildState>()
|
||||
// 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<string, Record<string, unknown>>()
|
||||
// Foreign sessions already resolved through session.get. Non-children stay
|
||||
// cached so unrelated concurrent sessions are checked at most once.
|
||||
const checked = new Set<string>()
|
||||
// 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<string, V2Event[]>()
|
||||
const hydrations = new Map<string, Promise<void>>()
|
||||
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<string, unknown> | 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<void> => {
|
||||
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<string, unknown>) => {
|
||||
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<string, FooterSubagentDetail> = child
|
||||
? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } }
|
||||
: {}
|
||||
return { tabs, details, permissions: [], questions: [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
798
packages/opencode/src/cli/cmd/run/stream-v2.transport.ts
Normal file
798
packages/opencode/src/cli/cmd/run/stream-v2.transport.ts
Normal file
|
|
@ -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<string, number>
|
||||
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<void>
|
||||
}
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
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<RunPromptPart, { type: "file" }>
|
||||
|
||||
type ToolState = {
|
||||
messageID: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
started: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
view: FooterView
|
||||
messageIDs: Set<string>
|
||||
text: Map<string, string>
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
tools: Map<string, ToolState>
|
||||
finishedTools: Set<string>
|
||||
wait?: Wait
|
||||
connected: boolean
|
||||
closed: boolean
|
||||
initial: boolean
|
||||
buffered?: RunV2Event[]
|
||||
errors: Set<string>
|
||||
}
|
||||
|
||||
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<void>((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: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
|
||||
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<SessionTurnInput, "model" | "variant" | "signal">) {
|
||||
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<SessionTransport> {
|
||||
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<void>((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<RunV2Event>
|
||||
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<void>((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(() => {})
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, FooterSubagentTab>
|
||||
details: Map<string, DetailState>
|
||||
}
|
||||
|
||||
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<T extends { id: string }>(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<typeof queueSnapshot>) {
|
||||
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, unknown>): 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<T>(input: Iterable<T>, limit: number) {
|
||||
const list = [...input]
|
||||
return list.slice(Math.max(0, list.length - limit))
|
||||
}
|
||||
|
||||
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
|
||||
const out = new Map<K, V>()
|
||||
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<string>) {
|
||||
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<string>) {
|
||||
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<string, number>
|
||||
}) {
|
||||
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<string, number>
|
||||
}) {
|
||||
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<string, number>
|
||||
}) {
|
||||
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<string, number>
|
||||
}) {
|
||||
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<string, number>
|
||||
}) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OpencodeClient["session"]["prompt"]>[0]
|
|||
|
||||
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
|
||||
|
||||
export type RunCommand = NonNullable<Awaited<ReturnType<OpencodeClient["command"]["list"]>>["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<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["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<string, unknown>
|
||||
}
|
||||
|
||||
export type RunProvider = {
|
||||
id: string
|
||||
name: string
|
||||
source?: string
|
||||
env?: string[]
|
||||
options?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
models: Record<string, RunProviderModel>
|
||||
}
|
||||
|
||||
export type RunPrompt = {
|
||||
messageID?: string
|
||||
|
|
@ -48,11 +102,16 @@ export type FooterQueuedPrompt = {
|
|||
prompt: RunPrompt
|
||||
}
|
||||
|
||||
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
|
||||
export type RunAgent = {
|
||||
name: string
|
||||
description?: string
|
||||
mode: "subagent" | "primary" | "all"
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
|
||||
|
||||
export type RunResource = RunResourceMap[string]
|
||||
export type RunReference = NonNullable<
|
||||
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["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[]
|
||||
}
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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(() => {}),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ type OpenApiSchema = {
|
|||
additionalProperties?: OpenApiSchema | boolean
|
||||
allOf?: OpenApiSchema[]
|
||||
anyOf?: OpenApiSchema[]
|
||||
contentMediaType?: string
|
||||
contentSchema?: OpenApiSchema
|
||||
description?: string
|
||||
enum?: Array<string | boolean>
|
||||
items?: OpenApiSchema
|
||||
|
|
@ -97,6 +99,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 <url> 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 <url>
|
||||
|
||||
|
|
@ -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 <url>
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<HOME>`.
|
||||
// `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(`<TMPDIR>${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), "<HOME>"],
|
||||
[PACKAGE_ROOT_PATTERN, "<HOME>"],
|
||||
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
|
||||
],
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
132
packages/opencode/test/cli/run/catalog.shared.test.ts
Normal file
132
packages/opencode/test/cli/run/catalog.shared.test.ts
Normal file
|
|
@ -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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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={() => ({
|
||||
|
|
|
|||
|
|
@ -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("<file name=\\\"image.png\\\">")
|
||||
}),
|
||||
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,
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, never>>) {
|
||||
function ok<T>(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<string, string>
|
||||
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<string, string>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["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<void>()
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof createSessionData>, 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",
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
1302
packages/opencode/test/cli/run/stream-v2.transport.test.ts
Normal file
1302
packages/opencode/test/cli/run/stream-v2.transport.test.ts
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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<typeof bootstrapSubagentData>[0]["messages"][number]
|
||||
type ChildMessage = Parameters<typeof bootstrapSubagentCalls>[0]["messages"][number]
|
||||
|
||||
function visible(commits: Array<Parameters<typeof entryBody>[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<typeof createSubagentData>, 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",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array>, into: string[]) {
|
|||
)
|
||||
}
|
||||
|
||||
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
||||
function isolatedEnv(home: string): Record<string, string> {
|
||||
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<RunResult>
|
||||
}
|
||||
|
||||
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
||||
export type SpawnOpts = {
|
||||
readonly timeoutMs?: number
|
||||
readonly env?: Record<string, string>
|
||||
readonly config?: ReturnType<typeof testProviderConfig> & Record<string, unknown>
|
||||
}
|
||||
|
||||
// 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<A, E>(
|
|||
.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<A, E>(
|
|||
// 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<A, E>(
|
|||
|
||||
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<A, E>(
|
|||
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<A, E>(
|
|||
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<A, E>(
|
|||
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<A, E>(
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -249,8 +249,14 @@ export type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]
|
|||
export type Endpoint6_0Output = EffectValue<ReturnType<RawClient["server.model"]["model.list"]>>
|
||||
export type ModelListOperation<E = never> = (input?: Endpoint6_0Input) => Effect.Effect<Endpoint6_0Output, E>
|
||||
|
||||
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
|
||||
export type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
|
||||
export type Endpoint6_1Output = EffectValue<ReturnType<RawClient["server.model"]["model.default"]>>
|
||||
export type ModelDefaultOperation<E = never> = (input?: Endpoint6_1Input) => Effect.Effect<Endpoint6_1Output, E>
|
||||
|
||||
export interface ModelApi<E = never> {
|
||||
readonly list: ModelListOperation<E>
|
||||
readonly default: ModelDefaultOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<V2ModelDefaultResponses, V2ModelDefaultErrors, ThrowOnError>({
|
||||
url: "/api/model/default",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Generate extends HeyApiClient {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>` to the durable `session.next.synthetic.1` event data so synthetic messages can carry a durable ledger (e.g. lazy-instruction dedup paths).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue