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:
@@ -311,7 +311,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
|
|||||||
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
|
||||||
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
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_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
||||||
type Endpoint7_0Input = {
|
type Endpoint7_0Input = {
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import type {
|
|||||||
MessageListOutput,
|
MessageListOutput,
|
||||||
ModelListInput,
|
ModelListInput,
|
||||||
ModelListOutput,
|
ModelListOutput,
|
||||||
|
ModelDefaultInput,
|
||||||
|
ModelDefaultOutput,
|
||||||
GenerateTextInput,
|
GenerateTextInput,
|
||||||
GenerateTextOutput,
|
GenerateTextOutput,
|
||||||
ProviderListInput,
|
ProviderListInput,
|
||||||
@@ -627,6 +629,18 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
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: {
|
generate: {
|
||||||
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
|
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 = {
|
export type GenerateTextInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
@@ -3533,6 +3594,19 @@ export type EventSubscribeOutput =
|
|||||||
readonly delivery: "steer" | "queue"
|
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 id: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
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.prompt.admitted": () => Effect.void,
|
||||||
|
"session.next.execution.settled": () => Effect.void,
|
||||||
"session.next.context.updated": (event) =>
|
"session.next.context.updated": (event) =>
|
||||||
adapter.appendMessage(
|
adapter.appendMessage(
|
||||||
SessionMessage.System.make({
|
SessionMessage.System.make({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
isContextOverflowFailure,
|
isContextOverflowFailure,
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
} from "@opencode-ai/llm"
|
} 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 { AgentV2 } from "../../agent"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Database } from "../../database/database"
|
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 sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
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({
|
return Service.of({
|
||||||
run,
|
run,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,32 +43,8 @@ export const AttachCommand = cmd({
|
|||||||
alias: ["u"],
|
alias: ["u"],
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
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) => {
|
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 = (() => {
|
const directory = (() => {
|
||||||
if (!args.dir) return undefined
|
if (!args.dir) return undefined
|
||||||
try {
|
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")
|
const { TuiConfig } = await import("@/config/tui")
|
||||||
if (args.fork && !args.continue && !args.session) {
|
if (args.fork && !args.continue && !args.session) {
|
||||||
UI.error("--fork requires --continue or --session")
|
UI.error("--fork requires --continue or --session")
|
||||||
|
|||||||
@@ -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 type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
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:
|
// Handles three modes:
|
||||||
// 1. Non-interactive (default): sends a single prompt, streams events to
|
// 1. Non-interactive (default): sends a single prompt, streams events to
|
||||||
// stdout, and exits when the session goes idle.
|
// 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).
|
// 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.
|
// opencode server and runs interactive mode against it.
|
||||||
//
|
//
|
||||||
// Also supports `--command` for slash-command execution, `--format json` for
|
// 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 { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { FormatError, FormatUnknownError } from "../error"
|
import { FormatError, FormatUnknownError } from "../error"
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
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"]
|
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||||
|
|
||||||
@@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined {
|
|||||||
return value + "\n" + piped
|
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 FilePart = {
|
||||||
type: "file"
|
type: "file"
|
||||||
url: string
|
url: string
|
||||||
@@ -68,6 +78,7 @@ type SessionInfo = {
|
|||||||
id: string
|
id: string
|
||||||
title?: string
|
title?: string
|
||||||
directory?: string
|
directory?: string
|
||||||
|
current?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function inline(info: Inline) {
|
function inline(info: Inline) {
|
||||||
@@ -158,10 +169,6 @@ export const RunCommand = effectCmd({
|
|||||||
describe: "fork the session before continuing (requires --continue or --session)",
|
describe: "fork the session before continuing (requires --continue or --session)",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
})
|
})
|
||||||
.option("share", {
|
|
||||||
type: "boolean",
|
|
||||||
describe: "share the session",
|
|
||||||
})
|
|
||||||
.option("model", {
|
.option("model", {
|
||||||
type: "string",
|
type: "string",
|
||||||
alias: ["m"],
|
alias: ["m"],
|
||||||
@@ -217,11 +224,6 @@ export const RunCommand = effectCmd({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
describe: "show thinking blocks",
|
describe: "show thinking blocks",
|
||||||
})
|
})
|
||||||
.option("mini", {
|
|
||||||
type: "boolean",
|
|
||||||
hidden: true,
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
.option("replay", {
|
.option("replay", {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
default: true,
|
default: true,
|
||||||
@@ -270,7 +272,7 @@ export const RunCommand = effectCmd({
|
|||||||
const localInstance = yield* InstanceRef
|
const localInstance = yield* InstanceRef
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
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 auto = args.auto || args.yolo || args["dangerously-skip-permissions"]
|
||||||
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||||
const die = (message: string): never => {
|
const die = (message: string): never => {
|
||||||
@@ -290,23 +292,23 @@ export const RunCommand = effectCmd({
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
|
|
||||||
if (interactive && args.command) {
|
if (interactive && args.command) {
|
||||||
die("--mini cannot be used with --command")
|
die("opencode mini cannot be used with --command")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive && args._?.[0] !== "mini") {
|
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) {
|
if (args.demo && !interactive) {
|
||||||
die("--demo requires --mini")
|
die("--demo requires opencode mini")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive && args.format === "json") {
|
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) {
|
if (args["replay-limit"] !== undefined && !interactive) {
|
||||||
die("--replay-limit requires --mini")
|
die("--replay-limit requires opencode mini")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -317,7 +319,7 @@ export const RunCommand = effectCmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (interactive && !process.stdout.isTTY) {
|
if (interactive && !process.stdout.isTTY) {
|
||||||
die("--mini requires a TTY stdout")
|
die("opencode mini requires a TTY stdout")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
@@ -355,6 +357,12 @@ export const RunCommand = effectCmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files: FilePart[] = []
|
const files: FilePart[] = []
|
||||||
|
const fileInputs: Array<{
|
||||||
|
filePath: string
|
||||||
|
resolvedPath: string
|
||||||
|
stat: ReturnType<typeof Filesystem.stat>
|
||||||
|
isDirectory: boolean
|
||||||
|
}> = []
|
||||||
if (args.file) {
|
if (args.file) {
|
||||||
const list = Array.isArray(args.file) ? args.file : [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}`)
|
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
fileInputs.push({ filePath, resolvedPath, stat, isDirectory })
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +416,53 @@ export const RunCommand = effectCmd({
|
|||||||
pattern: "*",
|
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() {
|
function title() {
|
||||||
if (args.title === undefined) return
|
if (args.title === undefined) return
|
||||||
@@ -453,58 +470,122 @@ export const RunCommand = effectCmd({
|
|||||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
|
||||||
if (args.session) {
|
const listed = await sdk.v2.session
|
||||||
const current = await sdk.session
|
.list({
|
||||||
.get({
|
directory: await current(sdk),
|
||||||
sessionID: args.session,
|
limit: 50,
|
||||||
})
|
order: "desc",
|
||||||
.catch(() => undefined)
|
})
|
||||||
|
.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) {
|
return {
|
||||||
UI.error("Session not found")
|
id: legacy?.id ?? sessionID,
|
||||||
process.exit(1)
|
title: legacy?.title,
|
||||||
}
|
directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk),
|
||||||
|
current: transcript !== "legacy",
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
id: current.data.id,
|
id: forked.data.data.id,
|
||||||
title: current.data.title,
|
title: forked.data.data.title,
|
||||||
directory: current.data.directory,
|
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) {
|
return {
|
||||||
const forked = await sdk.session.fork({
|
id,
|
||||||
sessionID: base.id,
|
title: forked.data?.title ?? session.title,
|
||||||
})
|
directory: forked.data?.directory ?? session.directory,
|
||||||
const id = forked.data?.id
|
current: false,
|
||||||
if (!id) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
await Bun.sleep(25)
|
||||||
id,
|
}
|
||||||
title: forked.data?.title ?? base.title,
|
}
|
||||||
directory: forked.data?.directory ?? base.directory,
|
|
||||||
|
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) {
|
if (base) {
|
||||||
@@ -512,6 +593,23 @@ export const RunCommand = effectCmd({
|
|||||||
id: base.id,
|
id: base.id,
|
||||||
title: base.title,
|
title: base.title,
|
||||||
directory: base.directory,
|
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,
|
id,
|
||||||
title: result.data?.title ?? name,
|
title: result.data?.title ?? name,
|
||||||
directory: result.data?.directory,
|
directory: result.data?.directory,
|
||||||
|
current: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
async function currentRootSession(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||||
const cfg = await sdk.config.get()
|
const response = await sdk.v2.session.list({
|
||||||
if (!cfg.data) return
|
directory: await current(sdk),
|
||||||
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
|
limit: 50,
|
||||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
order: "desc",
|
||||||
if (error instanceof Error && error.message.includes("disabled")) {
|
|
||||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
|
||||||
}
|
|
||||||
return { error }
|
|
||||||
})
|
})
|
||||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
const root = (response.data?.data ?? [])
|
||||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
.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(
|
async function createFreshSession(
|
||||||
sdk: OpencodeClient,
|
sdk: OpencodeClient,
|
||||||
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
|
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
|
||||||
): Promise<SessionInfo> {
|
): Promise<SessionInfo> {
|
||||||
const result = await sdk.session.create({
|
const name = args.title !== undefined && args.title !== "" ? args.title : undefined
|
||||||
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
|
const result = await sdk.v2.session.create({
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model
|
model: input.model
|
||||||
? {
|
? {
|
||||||
@@ -561,17 +674,18 @@ export const RunCommand = effectCmd({
|
|||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
permission: [...rules],
|
location: { directory: await current(sdk) },
|
||||||
})
|
})
|
||||||
const id = result.data?.id
|
const created = result.data?.data
|
||||||
|
const id = created?.id
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("Failed to create session")
|
throw new Error("Failed to create session")
|
||||||
}
|
}
|
||||||
|
if (name) await sdk.v2.session.rename({ sessionID: id, title: name })
|
||||||
|
|
||||||
void share(sdk, id).catch(() => {})
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: result.data?.title,
|
title: name ?? created.title,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,8 +694,8 @@ export const RunCommand = effectCmd({
|
|||||||
return directory ?? root
|
return directory ?? root
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = await sdk.path
|
const next = await sdk.v2.location
|
||||||
.get()
|
.get(undefined, { throwOnError: true })
|
||||||
.then((x) => x.data?.directory)
|
.then((x) => x.data?.directory)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -622,10 +736,7 @@ export const RunCommand = effectCmd({
|
|||||||
if (!args.agent) return undefined
|
if (!args.agent) return undefined
|
||||||
const name = args.agent
|
const name = args.agent
|
||||||
|
|
||||||
const modes = await sdk.app
|
const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined)
|
||||||
.agents(undefined, { throwOnError: true })
|
|
||||||
.then((x) => x.data ?? [])
|
|
||||||
.catch(() => undefined)
|
|
||||||
|
|
||||||
if (!modes) {
|
if (!modes) {
|
||||||
UI.println(
|
UI.println(
|
||||||
@@ -636,7 +747,7 @@ export const RunCommand = effectCmd({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = modes.find((a) => a.name === name)
|
const agent = modes.find((item) => item.name === name)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
UI.println(
|
UI.println(
|
||||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||||
@@ -823,9 +934,33 @@ export const RunCommand = effectCmd({
|
|||||||
// Validate agent if specified
|
// Validate agent if specified
|
||||||
const agent = await pickAgent(client)
|
const agent = await pickAgent(client)
|
||||||
|
|
||||||
await share(client, sessionID)
|
|
||||||
|
|
||||||
if (!interactive) {
|
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 events = await client.event.subscribe()
|
||||||
const completed = loop(client, events).catch((e) => {
|
const completed = loop(client, events).catch((e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({
|
|||||||
directory: cwd,
|
directory: cwd,
|
||||||
sessionID,
|
sessionID,
|
||||||
sessionTitle: sess.title,
|
sessionTitle: sess.title,
|
||||||
resume: Boolean(args.session || args.continue) && !args.fork,
|
resume: Boolean(args.session || args.continue),
|
||||||
replay,
|
replay,
|
||||||
replayLimit: args["replay-limit"],
|
replayLimit: args["replay-limit"],
|
||||||
agent,
|
agent,
|
||||||
@@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({
|
|||||||
fetch: fetchFn,
|
fetch: fetchFn,
|
||||||
resolveAgent: localAgent,
|
resolveAgent: localAgent,
|
||||||
session,
|
session,
|
||||||
share,
|
|
||||||
createSession: createFreshSession,
|
createSession: createFreshSession,
|
||||||
agent: args.agent,
|
agent: args.agent,
|
||||||
model,
|
model,
|
||||||
@@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
continue: input.continue,
|
continue: input.continue,
|
||||||
session: input.session,
|
session: input.session,
|
||||||
fork: input.fork,
|
fork: input.fork,
|
||||||
share: undefined,
|
|
||||||
model: input.model,
|
model: input.model,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
format: "default",
|
format: "default",
|
||||||
@@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
"dangerously-skip-permissions": false,
|
"dangerously-skip-permissions": false,
|
||||||
dangerouslySkipPermissions: false,
|
dangerouslySkipPermissions: false,
|
||||||
demo: input.demo ?? false,
|
demo: input.demo ?? false,
|
||||||
})
|
} as Parameters<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 info = createMemo(() => permissionInfo(props.request))
|
||||||
const ft = createMemo(() => toolFiletype(info().file))
|
const ft = createMemo(() => toolFiletype(info().file))
|
||||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
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 busy = createMemo(() => state().submitting)
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
if (state().stage === "always") {
|
if (state().stage === "always") {
|
||||||
@@ -165,7 +167,7 @@ export function RunPermissionBody(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shift = (dir: -1 | 1) => {
|
const shift = (dir: -1 | 1) => {
|
||||||
setState((prev) => permissionShift(prev, dir))
|
setState((prev) => permissionShift(prev, dir, opts()))
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async (next: PermissionReply) => {
|
const submit = async (next: PermissionReply) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Prompt composer and its state machine for direct interactive mode.
|
// Prompt composer and its state machine for direct interactive mode.
|
||||||
//
|
//
|
||||||
// createPromptState() wires keymap command layers, history navigation, and
|
// 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
|
// It produces a PromptState that RunPromptBody renders as a slim single-line
|
||||||
// composer while the footer view renders any active menus below it.
|
// composer while the footer view renders any active menus below it.
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
@@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
|||||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import type { RunFooterTheme } from "./theme"
|
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_ROWS = FOOTER_MENU_ROWS
|
||||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||||
@@ -59,7 +59,7 @@ type PromptInput = {
|
|||||||
directory: string
|
directory: string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: Accessor<RunAgent[]>
|
agents: Accessor<RunAgent[]>
|
||||||
resources: Accessor<RunResource[]>
|
references: Accessor<RunReference[]>
|
||||||
commands: Accessor<RunCommand[] | undefined>
|
commands: Accessor<RunCommand[] | undefined>
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig
|
||||||
state: Accessor<FooterState>
|
state: Accessor<FooterState>
|
||||||
@@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
const resources = createMemo<Auto[]>(() => {
|
const references = createMemo<Auto[]>(() => {
|
||||||
return input.resources().map((item) => ({
|
return input.references().map((item) => ({
|
||||||
kind: "mention",
|
kind: "mention",
|
||||||
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
|
display: Locale.truncateMiddle("@" + item.name, width()),
|
||||||
value: item.name,
|
value: item.name,
|
||||||
description: item.description,
|
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
|
||||||
part: {
|
part: {
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: item.mimeType ?? "text/plain",
|
mime: "application/x-directory",
|
||||||
filename: item.name,
|
filename: item.name,
|
||||||
url: item.uri,
|
url: pathToFileURL(item.path).href,
|
||||||
source: {
|
source: {
|
||||||
type: "resource",
|
type: "file",
|
||||||
clientName: item.client,
|
path: item.name,
|
||||||
uri: item.uri,
|
|
||||||
text: {
|
text: {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 0,
|
end: 0,
|
||||||
@@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
},
|
},
|
||||||
{ initialValue: [] as Auto[] },
|
{ 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 skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
|
||||||
const hasSkillsCommand = createMemo(() =>
|
const hasSkillsCommand = createMemo(() =>
|
||||||
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
|
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
|
||||||
@@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return [
|
return [
|
||||||
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||||
...files(),
|
...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
|
diffStyle?: RunDiffStyle
|
||||||
onCycle: (dir: -1 | 1) => void
|
onCycle: (dir: -1 | 1) => void
|
||||||
onClose: () => 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 theme = createMemo(() => props.theme())
|
||||||
const footer = createMemo(() => theme().footer)
|
const footer = createMemo(() => theme().footer)
|
||||||
@@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: {
|
|||||||
))
|
))
|
||||||
let scroll: ScrollBoxRenderable | undefined
|
let scroll: ScrollBoxRenderable | undefined
|
||||||
|
|
||||||
|
const interruptHint = createMemo(() => {
|
||||||
|
if (tab()?.status !== "running") return undefined
|
||||||
|
return props.interrupt?.()
|
||||||
|
})
|
||||||
|
|
||||||
useKeyboard((event) => {
|
useKeyboard((event) => {
|
||||||
if (!props.active()) {
|
if (!props.active()) {
|
||||||
return
|
return
|
||||||
@@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: {
|
|||||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</text>
|
</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}>
|
<Show when={props.total() > 1 && props.index() > 0}>
|
||||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||||
{props.index()} of {props.total()}
|
{props.index()} of {props.total()}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import type {
|
|||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
RunResource,
|
RunReference,
|
||||||
RunTuiConfig,
|
RunTuiConfig,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
@@ -71,7 +71,7 @@ type RunFooterOptions = {
|
|||||||
directory: string
|
directory: string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: RunAgent[]
|
agents: RunAgent[]
|
||||||
resources: RunResource[]
|
references: RunReference[]
|
||||||
commands?: RunCommand[]
|
commands?: RunCommand[]
|
||||||
wrote?: boolean
|
wrote?: boolean
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
@@ -97,6 +97,7 @@ type RunFooterOptions = {
|
|||||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onExit?: () => void
|
onExit?: () => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
treeSitterClient?: TreeSitterClient
|
treeSitterClient?: TreeSitterClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,8 +181,8 @@ export class RunFooter implements FooterApi {
|
|||||||
private rows = TEXTAREA_MIN_ROWS
|
private rows = TEXTAREA_MIN_ROWS
|
||||||
private agents: Accessor<RunAgent[]>
|
private agents: Accessor<RunAgent[]>
|
||||||
private setAgents: Setter<RunAgent[]>
|
private setAgents: Setter<RunAgent[]>
|
||||||
private resources: Accessor<RunResource[]>
|
private references: Accessor<RunReference[]>
|
||||||
private setResources: Setter<RunResource[]>
|
private setReferences: Setter<RunReference[]>
|
||||||
private commands: Accessor<RunCommand[] | undefined>
|
private commands: Accessor<RunCommand[] | undefined>
|
||||||
private setCommands: Setter<RunCommand[] | undefined>
|
private setCommands: Setter<RunCommand[] | undefined>
|
||||||
private providers: Accessor<RunProvider[] | undefined>
|
private providers: Accessor<RunProvider[] | undefined>
|
||||||
@@ -255,9 +256,9 @@ export class RunFooter implements FooterApi {
|
|||||||
const [agents, setAgents] = createSignal(options.agents)
|
const [agents, setAgents] = createSignal(options.agents)
|
||||||
this.agents = agents
|
this.agents = agents
|
||||||
this.setAgents = setAgents
|
this.setAgents = setAgents
|
||||||
const [resources, setResources] = createSignal(options.resources)
|
const [references, setReferences] = createSignal(options.references)
|
||||||
this.resources = resources
|
this.references = references
|
||||||
this.setResources = setResources
|
this.setReferences = setReferences
|
||||||
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
|
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
|
||||||
this.commands = commands
|
this.commands = commands
|
||||||
this.setCommands = setCommands
|
this.setCommands = setCommands
|
||||||
@@ -311,7 +312,7 @@ export class RunFooter implements FooterApi {
|
|||||||
queuedPrompts: footer.queuedPrompts,
|
queuedPrompts: footer.queuedPrompts,
|
||||||
findFiles: options.findFiles,
|
findFiles: options.findFiles,
|
||||||
agents: footer.agents,
|
agents: footer.agents,
|
||||||
resources: footer.resources,
|
references: footer.references,
|
||||||
commands: footer.commands,
|
commands: footer.commands,
|
||||||
providers: footer.providers,
|
providers: footer.providers,
|
||||||
currentModel: footer.currentModel,
|
currentModel: footer.currentModel,
|
||||||
@@ -341,6 +342,7 @@ export class RunFooter implements FooterApi {
|
|||||||
onLayout: footer.syncLayout,
|
onLayout: footer.syncLayout,
|
||||||
onStatus: footer.setStatus,
|
onStatus: footer.setStatus,
|
||||||
onSubagentSelect: options.onSubagentSelect,
|
onSubagentSelect: options.onSubagentSelect,
|
||||||
|
onSubagentInterrupt: options.onSubagentInterrupt,
|
||||||
onQueuedRemove: footer.handleQueuedRemove,
|
onQueuedRemove: footer.handleQueuedRemove,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -411,7 +413,7 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setAgents(next.agents)
|
this.setAgents(next.agents)
|
||||||
this.setResources(next.resources)
|
this.setReferences(next.references)
|
||||||
if (next.commands !== undefined) {
|
if (next.commands !== undefined) {
|
||||||
this.setCommands(next.commands)
|
this.setCommands(next.commands)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import type {
|
|||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
RunResource,
|
RunReference,
|
||||||
RunTuiConfig,
|
RunTuiConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { RunTheme } from "./theme"
|
import type { RunTheme } from "./theme"
|
||||||
@@ -74,7 +74,7 @@ type RunFooterViewProps = {
|
|||||||
directory: string
|
directory: string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: () => RunAgent[]
|
agents: () => RunAgent[]
|
||||||
resources: () => RunResource[]
|
references: () => RunReference[]
|
||||||
commands: () => RunCommand[] | undefined
|
commands: () => RunCommand[] | undefined
|
||||||
providers: () => RunProvider[] | undefined
|
providers: () => RunProvider[] | undefined
|
||||||
currentModel: () => RunInput["model"]
|
currentModel: () => RunInput["model"]
|
||||||
@@ -108,6 +108,7 @@ type RunFooterViewProps = {
|
|||||||
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
||||||
onStatus: (text: string) => void
|
onStatus: (text: string) => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
props.tuiConfig,
|
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(
|
const interrupt = useKeymapSelector(
|
||||||
(keymap: OpenTuiKeymap) =>
|
(keymap: OpenTuiKeymap) =>
|
||||||
formatKeySequence(
|
formatKeySequence(
|
||||||
@@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
findFiles: props.findFiles,
|
findFiles: props.findFiles,
|
||||||
agents: props.agents,
|
agents: props.agents,
|
||||||
resources: props.resources,
|
references: props.references,
|
||||||
commands: props.commands,
|
commands: props.commands,
|
||||||
tuiConfig: props.tuiConfig,
|
tuiConfig: props.tuiConfig,
|
||||||
state: props.state,
|
state: props.state,
|
||||||
@@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
|
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
@@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
|
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(() => {
|
createEffect(() => {
|
||||||
const current = route()
|
const current = route()
|
||||||
if (current.type !== "subagent") {
|
if (current.type !== "subagent") {
|
||||||
@@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
diffStyle={props.diffStyle}
|
diffStyle={props.diffStyle}
|
||||||
onCycle={cycleTab}
|
onCycle={cycleTab}
|
||||||
onClose={closeTab}
|
onClose={closeTab}
|
||||||
|
interrupt={() => subagentInterruptShortcut() || undefined}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -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 {
|
export function permissionShift(
|
||||||
const list = permissionOptions(state.stage)
|
state: PermissionBodyState,
|
||||||
|
dir: -1 | 1,
|
||||||
|
list = permissionOptions(state.stage),
|
||||||
|
): PermissionBodyState {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { resolve } from "@opencode-ai/tui/config"
|
import { resolve } from "@opencode-ai/tui/config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
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 { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { loadRunProviders } from "./catalog.shared"
|
||||||
import { reusePendingTask } from "./runtime.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 type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||||
import { pickVariant } from "./variant.shared"
|
import { pickVariant } from "./variant.shared"
|
||||||
|
|
||||||
@@ -95,20 +98,7 @@ const layer = Layer.effect(
|
|||||||
directory: string,
|
directory: string,
|
||||||
model: RunInput["model"],
|
model: RunInput["model"],
|
||||||
) {
|
) {
|
||||||
const connected = yield* Effect.promise(() =>
|
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
|
||||||
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 limits = Object.fromEntries(
|
const limits = Object.fromEntries(
|
||||||
providers.flatMap((provider) =>
|
providers.flatMap((provider) =>
|
||||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
||||||
@@ -143,7 +133,7 @@ const layer = Layer.effect(
|
|||||||
sessionID: string,
|
sessionID: string,
|
||||||
model: RunInput["model"],
|
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) {
|
if (!session) {
|
||||||
return emptySessionInfo()
|
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.
|
// Fetches available variants and context limits for every provider/model pair.
|
||||||
export async function resolveModelInfo(
|
export async function resolveModelInfo(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type {
|
|||||||
RunAgent,
|
RunAgent,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunResource,
|
RunReference,
|
||||||
RunTuiConfig,
|
RunTuiConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { formatModelLabel } from "./variant.shared"
|
import { formatModelLabel } from "./variant.shared"
|
||||||
@@ -55,7 +55,7 @@ export type LifecycleInput = {
|
|||||||
directory: string
|
directory: string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: RunAgent[]
|
agents: RunAgent[]
|
||||||
resources: RunResource[]
|
references: RunReference[]
|
||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
getSessionID?: () => string | undefined
|
getSessionID?: () => string | undefined
|
||||||
@@ -75,6 +75,7 @@ export type LifecycleInput = {
|
|||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Lifecycle = {
|
export type Lifecycle = {
|
||||||
@@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
findFiles: input.findFiles,
|
findFiles: input.findFiles,
|
||||||
agents: input.agents,
|
agents: input.agents,
|
||||||
resources: input.resources,
|
references: input.references,
|
||||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||||
...labels,
|
...labels,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubagentSelect: input.onSubagentSelect,
|
onSubagentSelect: input.onSubagentSelect,
|
||||||
|
onSubagentInterrupt: input.onSubagentInterrupt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sigint = () => {
|
const sigint = () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import * as tty from "node:tty"
|
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 = {
|
type InteractiveStdin = {
|
||||||
stdin: NodeJS.ReadStream
|
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,
|
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
|
||||||
// and prompt queue together into a single session loop. Two entry points:
|
// and prompt queue together into a single session loop. Two entry points:
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { MessageID } from "@/session/schema"
|
import { MessageID } from "@/session/schema"
|
||||||
|
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
||||||
import { createRunDemo } from "./demo"
|
import { createRunDemo } from "./demo"
|
||||||
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||||
@@ -62,7 +63,6 @@ type RunLocalInput = {
|
|||||||
fetch: typeof globalThis.fetch
|
fetch: typeof globalThis.fetch
|
||||||
resolveAgent: () => Promise<string | undefined>
|
resolveAgent: () => Promise<string | undefined>
|
||||||
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
|
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
|
||||||
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
|
|
||||||
createSession?: CreateSession
|
createSession?: CreateSession
|
||||||
agent: RunInput["agent"]
|
agent: RunInput["agent"]
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
@@ -77,7 +77,7 @@ type RunLocalInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StreamTransportModule = Pick<
|
type StreamTransportModule = Pick<
|
||||||
Awaited<typeof import("./stream.transport")>,
|
Awaited<typeof import("./stream-v2.transport")>,
|
||||||
"createSessionTransport" | "formatUnknownError"
|
"createSessionTransport" | "formatUnknownError"
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -164,11 +164,9 @@ async function resolveExitTitle(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.sdk.session
|
return ctx.sdk.v2.session
|
||||||
.get({
|
.get({ sessionID: state.sessionID })
|
||||||
sessionID: state.sessionID,
|
.then((x) => x.data?.data.title)
|
||||||
})
|
|
||||||
.then((x) => x.data?.title)
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +231,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
agents: [],
|
agents: [],
|
||||||
resources: [],
|
references: [],
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
sessionTitle: state.sessionTitle,
|
sessionTitle: state.sessionTitle,
|
||||||
getSessionID: () => state.sessionID,
|
getSessionID: () => state.sessionID,
|
||||||
@@ -250,21 +248,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
log?.write("send.permission.reply", next)
|
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) => {
|
onQuestionReply: async (next) => {
|
||||||
if (state.demo?.questionReply(next)) {
|
if (state.demo?.questionReply(next)) {
|
||||||
return
|
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) => {
|
onQuestionReject: async (next) => {
|
||||||
if (state.demo?.questionReject(next)) {
|
if (state.demo?.questionReject(next)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.sdk.question.reject(next)
|
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
|
||||||
},
|
},
|
||||||
onCycleVariant: () => {
|
onCycleVariant: () => {
|
||||||
if (!state.model || state.variants.length === 0) {
|
if (!state.model || state.variants.length === 0) {
|
||||||
@@ -339,22 +341,30 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
},
|
},
|
||||||
onInterrupt: () => {
|
onInterrupt: () => {
|
||||||
if (!hasSession(input, state) || state.aborting) {
|
if (!hasSession(input, state) || state.aborting) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
state.aborting = true
|
state.aborting = true
|
||||||
void ctx.sdk.session
|
void (state.stream
|
||||||
.abort({
|
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||||
sessionID: state.sessionID,
|
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
state.aborting = false
|
state.aborting = false
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
onBackground: () => {
|
onBackground: () => {
|
||||||
if (!hasSession(input, state)) return
|
if (!hasSession(input, state)) {
|
||||||
void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
|
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) => {
|
onSubagentSelect: (sessionID) => {
|
||||||
state.selectSubagent?.(sessionID)
|
state.selectSubagent?.(sessionID)
|
||||||
@@ -373,19 +383,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const [agents, resources, commands] = await Promise.all([
|
const [agents, references, commands] = await Promise.all([
|
||||||
ctx.sdk.app
|
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
|
||||||
.agents({ directory: ctx.directory })
|
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
|
||||||
.then((x) => x.data ?? [])
|
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
|
||||||
.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(() => []),
|
|
||||||
])
|
])
|
||||||
if (footer.isClosed) {
|
if (footer.isClosed) {
|
||||||
return
|
return
|
||||||
@@ -394,7 +395,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
footer.event({
|
footer.event({
|
||||||
type: "catalog",
|
type: "catalog",
|
||||||
agents,
|
agents,
|
||||||
resources,
|
references,
|
||||||
commands,
|
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 = () => {
|
const ensureStream = () => {
|
||||||
if (state.stream) {
|
if (state.stream) {
|
||||||
return state.stream
|
return state.stream
|
||||||
@@ -758,7 +759,6 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
|||||||
throw new Error("Session not found")
|
throw new Error("Session not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
void input.share(sdk, next.id).catch(() => {})
|
|
||||||
return {
|
return {
|
||||||
sessionID: next.id,
|
sessionID: next.id,
|
||||||
sessionTitle: next.title,
|
sessionTitle: next.title,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// - FooterOutput: status bar patches and view transitions (permission, question)
|
// - FooterOutput: status bar patches and view transitions (permission, question)
|
||||||
//
|
//
|
||||||
// The reducer mutates SessionData in place for performance but has no
|
// The reducer mutates SessionData in place for performance but has no
|
||||||
// external side effects -- no IO, no footer calls. The caller
|
// external side effects -- no IO, no footer calls. The demo runtime
|
||||||
// (stream.transport.ts) feeds events in and forwards output to the footer
|
// (demo.ts) feeds events in and forwards output to the footer through
|
||||||
// through stream.ts.
|
// stream.ts; the current transport reuses the blocker helpers below.
|
||||||
//
|
//
|
||||||
// Key design decisions:
|
// Key design decisions:
|
||||||
//
|
//
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
// `data.questions`. The footer shows whichever is first. When a reply
|
// `data.questions`. The footer shows whichever is first. When a reply
|
||||||
// event arrives, the queue entry is removed and the footer falls back
|
// event arrives, the queue entry is removed and the footer falls back
|
||||||
// to the next pending request or to the prompt view.
|
// 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 * as Locale from "@/util/locale"
|
||||||
import { toolView } from "./tool"
|
import { toolView } from "./tool"
|
||||||
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
|
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
|
||||||
@@ -280,33 +280,6 @@ function remove(list: Array<{ id: string }>, id: string): boolean {
|
|||||||
return true
|
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 {
|
function key(msg: string, call: string): string {
|
||||||
return `${msg}:${call}`
|
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
|
// 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.
|
// 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> {
|
export async function resolveCurrentSession(
|
||||||
const response = await sdk.session.messages({
|
sdk: RunInput["sdk"],
|
||||||
sessionID,
|
sessionID: string,
|
||||||
limit,
|
limit = LIMIT,
|
||||||
})
|
): Promise<RunSession> {
|
||||||
return createSession(response.data ?? [])
|
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[] {
|
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
|||||||
lines,
|
lines,
|
||||||
body_left + label.length,
|
body_left + label.length,
|
||||||
top + 1,
|
top + 1,
|
||||||
`opencode --mini -s ${meta.session_id}`,
|
`opencode mini -s ${meta.session_id}`,
|
||||||
right,
|
right,
|
||||||
undefined,
|
undefined,
|
||||||
TextAttributes.BOLD,
|
TextAttributes.BOLD,
|
||||||
|
|||||||
@@ -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: [] }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { StreamCommit } from "./types"
|
||||||
import type { SessionMessages } from "./session.shared"
|
|
||||||
import type { RunProvider, StreamCommit } from "./types"
|
|
||||||
|
|
||||||
export function turnSummaryCommit(input: {
|
export function turnSummaryCommit(input: {
|
||||||
agent: string
|
agent: string
|
||||||
@@ -21,27 +19,3 @@ export function turnSummaryCommit(input: {
|
|||||||
messageID: input.messageID,
|
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
|
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
|
||||||
// session transcript, and a mutable footer for prompt input, status, and
|
// 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 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 = {
|
export type RunPrompt = {
|
||||||
messageID?: string
|
messageID?: string
|
||||||
@@ -48,11 +102,16 @@ export type FooterQueuedPrompt = {
|
|||||||
prompt: RunPrompt
|
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 RunReference = NonNullable<
|
||||||
|
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
|
||||||
export type RunResource = RunResourceMap[string]
|
>["data"][number]
|
||||||
|
|
||||||
export type RunInput = {
|
export type RunInput = {
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
@@ -224,7 +283,7 @@ export type FooterEvent =
|
|||||||
| {
|
| {
|
||||||
type: "catalog"
|
type: "catalog"
|
||||||
agents: RunAgent[]
|
agents: RunAgent[]
|
||||||
resources: RunResource[]
|
references: RunReference[]
|
||||||
commands?: RunCommand[]
|
commands?: RunCommand[]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import path from "path"
|
|||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Context, Effect, Layer } from "effect"
|
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 { makeRuntime } from "@/effect/run-service"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
@@ -136,69 +138,69 @@ function state(value: unknown): ModelState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) {
|
const layer = Layer.fresh(
|
||||||
return Layer.fresh(
|
Layer.effect(
|
||||||
Layer.effect(
|
Service,
|
||||||
Service,
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const file = yield* FSUtil.Service
|
||||||
const file = yield* FSUtil.Service
|
|
||||||
|
|
||||||
const read = Effect.fn("RunVariant.read")(function* () {
|
const read = Effect.fn("RunVariant.read")(function* () {
|
||||||
return yield* file.readJson(MODEL_FILE).pipe(
|
return yield* file.readJson(MODEL_FILE).pipe(
|
||||||
Effect.map(state),
|
Effect.map(state),
|
||||||
Effect.catchCause(() => Effect.succeed(state(undefined))),
|
Effect.catchCause(() => Effect.succeed(state(undefined))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
|
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return (yield* read()).variant?.[variantKey(model)]
|
return (yield* read()).variant?.[variantKey(model)]
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
|
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
|
||||||
model: RunInput["model"],
|
model: RunInput["model"],
|
||||||
variant: string | undefined,
|
variant: string | undefined,
|
||||||
) {
|
) {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = yield* read()
|
const current = yield* read()
|
||||||
const next = {
|
const next = {
|
||||||
...current.variant,
|
...current.variant,
|
||||||
}
|
}
|
||||||
const key = variantKey(model)
|
const key = variantKey(model)
|
||||||
if (variant) {
|
if (variant) {
|
||||||
next[key] = variant
|
next[key] = variant
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!variant) {
|
if (!variant) {
|
||||||
delete next[key]
|
delete next[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* file
|
yield* file
|
||||||
.writeJson(MODEL_FILE, {
|
.writeJson(MODEL_FILE, {
|
||||||
...current,
|
...current,
|
||||||
variant: next,
|
variant: next,
|
||||||
})
|
})
|
||||||
.pipe(Effect.orElseSucceed(() => undefined))
|
.pipe(Effect.orElseSucceed(() => undefined))
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolveSavedVariant,
|
resolveSavedVariant,
|
||||||
saveVariant,
|
saveVariant,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(fs)),
|
),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
/** @internal Exported for testing. */
|
/** @internal Exported for testing. */
|
||||||
export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime {
|
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
|
||||||
const runtime = makeRuntime(Service, createLayer(fs))
|
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
|
||||||
return {
|
return {
|
||||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
||||||
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
||||||
|
|||||||
@@ -89,73 +89,8 @@ export const TuiThreadCommand = cmd({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
default: false,
|
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) => {
|
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()
|
const unguard = win32InstallCtrlCGuard()
|
||||||
try {
|
try {
|
||||||
const { TuiConfig } = await import("@/config/tui")
|
const { TuiConfig } = await import("@/config/tui")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { GithubCommand } from "./cli/cmd/github"
|
|||||||
import { ExportCommand } from "./cli/cmd/export"
|
import { ExportCommand } from "./cli/cmd/export"
|
||||||
import { ImportCommand } from "./cli/cmd/import"
|
import { ImportCommand } from "./cli/cmd/import"
|
||||||
import { AttachCommand } from "./cli/cmd/attach"
|
import { AttachCommand } from "./cli/cmd/attach"
|
||||||
|
import { MiniCommand } from "./cli/cmd/mini"
|
||||||
import { TuiThreadCommand } from "./cli/cmd/tui"
|
import { TuiThreadCommand } from "./cli/cmd/tui"
|
||||||
import { AcpCommand } from "./cli/cmd/acp"
|
import { AcpCommand } from "./cli/cmd/acp"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
@@ -80,6 +81,7 @@ const cli = yargs(args)
|
|||||||
.completion("completion", "generate shell completion script")
|
.completion("completion", "generate shell completion script")
|
||||||
.command(AcpCommand)
|
.command(AcpCommand)
|
||||||
.command(McpCommand)
|
.command(McpCommand)
|
||||||
|
.command(MiniCommand)
|
||||||
.command(TuiThreadCommand)
|
.command(TuiThreadCommand)
|
||||||
.command(AttachCommand)
|
.command(AttachCommand)
|
||||||
.command(RunCommand)
|
.command(RunCommand)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type OpenApiSchema = {
|
|||||||
additionalProperties?: OpenApiSchema | boolean
|
additionalProperties?: OpenApiSchema | boolean
|
||||||
allOf?: OpenApiSchema[]
|
allOf?: OpenApiSchema[]
|
||||||
anyOf?: OpenApiSchema[]
|
anyOf?: OpenApiSchema[]
|
||||||
|
contentMediaType?: string
|
||||||
|
contentSchema?: OpenApiSchema
|
||||||
description?: string
|
description?: string
|
||||||
enum?: Array<string | boolean>
|
enum?: Array<string | boolean>
|
||||||
items?: OpenApiSchema
|
items?: OpenApiSchema
|
||||||
@@ -97,6 +99,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
normalizeComponentNames(spec)
|
normalizeComponentNames(spec)
|
||||||
collapseDuplicateComponents(spec)
|
collapseDuplicateComponents(spec)
|
||||||
|
normalizeV2EventSchemas(spec)
|
||||||
applyLegacySchemaOverrides(spec)
|
applyLegacySchemaOverrides(spec)
|
||||||
normalizeComponentDescriptions(spec)
|
normalizeComponentDescriptions(spec)
|
||||||
addLegacyErrorSchemas(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) {
|
function normalizeComponentNames(spec: OpenApiSpec) {
|
||||||
const schemas = spec.components?.schemas
|
const schemas = spec.components?.schemas
|
||||||
if (!schemas) return
|
if (!schemas) return
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
|||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
||||||
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
|
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
|
||||||
import { serveUIEffect } from "@/server/shared/ui"
|
import { serveUIEffect } from "@/server/shared/ui"
|
||||||
@@ -297,8 +298,11 @@ export function createRoutes(
|
|||||||
Layer.provide(sessionLocationLayer),
|
Layer.provide(sessionLocationLayer),
|
||||||
Layer.provide(locationLayer),
|
Layer.provide(locationLayer),
|
||||||
Layer.provide(PtyEnvironment.layer),
|
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(
|
Layer.provide(
|
||||||
AppNodeBuilderV1.build(SessionV2.node, [
|
AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, PluginRuntime.providerNode]), [
|
||||||
[LocationServiceMap.node, locationServiceMapV2],
|
[LocationServiceMap.node, locationServiceMapV2],
|
||||||
[SessionExecution.node, SessionExecutionLocal.node],
|
[SessionExecution.node, SessionExecutionLocal.node],
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import yargs from "yargs"
|
import yargs from "yargs"
|
||||||
|
import { MiniCommand } from "./cli/cmd/mini"
|
||||||
import { TuiThreadCommand } from "./cli/cmd/tui"
|
import { TuiThreadCommand } from "./cli/cmd/tui"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { hideBin } from "yargs/helpers"
|
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.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"
|
||||||
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
|
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
|
||||||
})
|
})
|
||||||
|
.command(MiniCommand)
|
||||||
.command(TuiThreadCommand)
|
.command(TuiThreadCommand)
|
||||||
.parse()
|
.parse()
|
||||||
|
|||||||
@@ -41,6 +41,34 @@ Options:
|
|||||||
--pure run without external plugins [boolean]"
|
--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`] = `
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
|
||||||
"opencode attach <url>
|
"opencode attach <url>
|
||||||
|
|
||||||
@@ -50,21 +78,17 @@ Positionals:
|
|||||||
url http://localhost:4096 [string] [required]
|
url http://localhost:4096 [string] [required]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help show help [boolean]
|
-h, --help show help [boolean]
|
||||||
-v, --version show version number [boolean]
|
-v, --version show version number [boolean]
|
||||||
--print-logs print logs to stderr [boolean]
|
--print-logs print logs to stderr [boolean]
|
||||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
--pure run without external plugins [boolean]
|
--pure run without external plugins [boolean]
|
||||||
--dir directory to run in [string]
|
--dir directory to run in [string]
|
||||||
-c, --continue continue the last session [boolean]
|
-c, --continue continue the last session [boolean]
|
||||||
-s, --session session id to continue [string]
|
-s, --session session id to continue [string]
|
||||||
--fork fork the session when continuing (use with --continue or --session) [boolean]
|
--fork fork the session when continuing (use with --continue or --session) [boolean]
|
||||||
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
|
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
|
||||||
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
|
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]"
|
||||||
[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]"
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
|
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]
|
-c, --continue continue the last session [boolean]
|
||||||
-s, --session session id to continue [string]
|
-s, --session session id to continue [string]
|
||||||
--fork fork the session before continuing (requires --continue or --session) [boolean]
|
--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]
|
-m, --model model to use in the format of provider/model [string]
|
||||||
--agent agent to use [string]
|
--agent agent to use [string]
|
||||||
--format format: default (formatted) or json (raw JSON events)
|
--format format: default (formatted) or json (raw JSON events)
|
||||||
@@ -403,6 +426,31 @@ Options:
|
|||||||
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
|
--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`] = `
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
|
||||||
"opencode mcp list
|
"opencode mcp list
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,27 @@
|
|||||||
// version (changes per release), so we'd snapshot a moving target.
|
// version (changes per release), so we'd snapshot a moving target.
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { cliIt } from "../../lib/cli-process"
|
||||||
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
|
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
|
// Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific
|
||||||
// rules:
|
// rules:
|
||||||
//
|
//
|
||||||
// 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to `<HOME>`.
|
// 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to `<HOME>`.
|
||||||
// `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows.
|
// `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
|
// pre-normalized default's character length, so different random home
|
||||||
// path widths produce different leading-whitespace counts (or even
|
// path widths produce different leading-whitespace counts (or even
|
||||||
// line-wraps onto a fresh line on Windows). `\s+` matches both forms.
|
// 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
|
// (the harness now uses FileSystem.makeTempDirectoryScoped under the
|
||||||
// hood). A `[a-z0-9]+` regex would leave uppercase chars trailing.
|
// hood). A `[a-z0-9]+` regex would leave uppercase chars trailing.
|
||||||
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), "<HOME>"],
|
[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>"]'],
|
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -45,6 +57,7 @@ function normalize(text: string): string {
|
|||||||
const TOP_LEVEL = [
|
const TOP_LEVEL = [
|
||||||
"acp",
|
"acp",
|
||||||
"mcp",
|
"mcp",
|
||||||
|
"mini",
|
||||||
"attach",
|
"attach",
|
||||||
"run",
|
"run",
|
||||||
"debug",
|
"debug",
|
||||||
@@ -69,6 +82,7 @@ const TOP_LEVEL = [
|
|||||||
// distinct argv shape, not every leaf. Add new entries when a subcommand
|
// distinct argv shape, not every leaf. Add new entries when a subcommand
|
||||||
// gains user-visible flags that we want to lock in.
|
// gains user-visible flags that we want to lock in.
|
||||||
const SUBCOMMANDS = [
|
const SUBCOMMANDS = [
|
||||||
|
["mini", "attach"],
|
||||||
["mcp", "list"],
|
["mcp", "list"],
|
||||||
["mcp", "add"],
|
["mcp", "add"],
|
||||||
["mcp", "auth"],
|
["mcp", "auth"],
|
||||||
@@ -101,7 +115,8 @@ describe("opencode CLI help-text snapshots", () => {
|
|||||||
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
|
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
|
||||||
expect(topLevel.exitCode).toBe(0)
|
expect(topLevel.exitCode).toBe(0)
|
||||||
expect(topLevel.stderr.endsWith("\n")).toBe(true)
|
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("--thinking")
|
||||||
expect(topLevel.stderr).not.toContain("--variant")
|
expect(topLevel.stderr).not.toContain("--variant")
|
||||||
expect(topLevel.stderr).not.toContain("--demo")
|
expect(topLevel.stderr).not.toContain("--demo")
|
||||||
|
|||||||
@@ -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"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
agents={() => []}
|
agents={() => []}
|
||||||
resources={() => []}
|
references={() => []}
|
||||||
commands={() => input.commands ?? []}
|
commands={() => input.commands ?? []}
|
||||||
providers={() => input.providers}
|
providers={() => input.providers}
|
||||||
currentModel={() => input.currentModel}
|
currentModel={() => input.currentModel}
|
||||||
@@ -934,7 +934,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
directory="/tmp"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
agents={() => []}
|
agents={() => []}
|
||||||
resources={() => []}
|
references={() => []}
|
||||||
commands={() => []}
|
commands={() => []}
|
||||||
providers={() => undefined}
|
providers={() => undefined}
|
||||||
currentModel={() => ({
|
currentModel={() => ({
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
// These exercise the real CLI binary against a TestLLMServer running in the
|
// 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
|
// 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.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 { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { reply } from "../../lib/llm-server"
|
import { reply } from "../../lib/llm-server"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { cliIt } from "../../lib/cli-process"
|
||||||
|
import { testProviderConfig } from "../../lib/test-provider"
|
||||||
|
|
||||||
describe("opencode run (non-interactive subprocess)", () => {
|
describe("opencode run (non-interactive subprocess)", () => {
|
||||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||||
@@ -28,7 +29,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* llm.push(
|
yield* llm.push(
|
||||||
reply().text(" before tool ").tool("bash", {
|
reply().text(" before tool ").tool("shell", {
|
||||||
command: "printf tool-output",
|
command: "printf tool-output",
|
||||||
description: "Print deterministic output",
|
description: "Print deterministic output",
|
||||||
}),
|
}),
|
||||||
@@ -89,7 +90,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* llm.push(
|
yield* llm.push(
|
||||||
reply().text("partial response").tool("bash", {
|
reply().text("partial response").tool("shell", {
|
||||||
command: "printf tool",
|
command: "printf tool",
|
||||||
description: "Print deterministic output",
|
description: "Print deterministic output",
|
||||||
}),
|
}),
|
||||||
@@ -168,7 +169,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* llm.push(
|
yield* llm.push(
|
||||||
reply().reason("reasoning").text("before").tool("bash", {
|
reply().reason("reasoning").text("before").tool("shell", {
|
||||||
command: "printf tool",
|
command: "printf tool",
|
||||||
description: "Print deterministic output",
|
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(events.find((event) => event.type === "tool_use")?.part).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
state: expect.objectContaining({ status: "completed" }),
|
state: expect.objectContaining({ status: "completed" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -217,7 +218,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* llm.push(
|
yield* llm.push(
|
||||||
reply().text("partial json").tool("bash", {
|
reply().text("partial json").tool("shell", {
|
||||||
command: "printf tool",
|
command: "printf tool",
|
||||||
description: "Print deterministic output",
|
description: "Print deterministic output",
|
||||||
}),
|
}),
|
||||||
@@ -227,16 +228,9 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
|
|
||||||
const events = opencode.parseJsonEvents(result.stdout)
|
const events = opencode.parseJsonEvents(result.stdout)
|
||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
expect(events.map((event) => event.type)).toEqual([
|
expect(events.map((event) => event.type)).toEqual(["step_start", "text", "tool_use", "step_finish"])
|
||||||
"step_start",
|
|
||||||
"text",
|
|
||||||
"tool_use",
|
|
||||||
"step_finish",
|
|
||||||
"step_start",
|
|
||||||
"step_finish",
|
|
||||||
])
|
|
||||||
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
|
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,
|
60_000,
|
||||||
)
|
)
|
||||||
@@ -245,29 +239,29 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
"rejects requested permissions by default and allows them with the dangerous flag",
|
"rejects requested permissions by default and allows them with the dangerous flag",
|
||||||
({ home, llm, opencode }) =>
|
({ home, llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
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")
|
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)
|
opencode.expectExit(denied, 0)
|
||||||
expect(denied.stderr).toContain("permission requested: bash")
|
expect(denied.stderr).toContain("permission requested: shell")
|
||||||
expect(denied.stdout).toBe("")
|
expect(denied.stdout).toBe("")
|
||||||
|
|
||||||
yield* llm.reset
|
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")
|
yield* llm.text("continued after approval")
|
||||||
const allowed = yield* opencode.run("request permission", {
|
const allowed = yield* opencode.run("request permission", {
|
||||||
permission: { bash: "ask" },
|
permission: { shell: "ask" },
|
||||||
extraArgs: ["--dangerously-skip-permissions"],
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
})
|
})
|
||||||
opencode.expectExit(allowed, 0)
|
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")
|
expect(allowed.stdout).toContain("continued after approval")
|
||||||
|
|
||||||
yield* llm.reset
|
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")
|
yield* llm.text("continued after explicit denial")
|
||||||
const explicitlyDenied = yield* opencode.run("request denied permission", {
|
const explicitlyDenied = yield* opencode.run("request denied permission", {
|
||||||
permission: { bash: "deny" },
|
permission: { shell: "deny" },
|
||||||
extraArgs: ["--dangerously-skip-permissions"],
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
})
|
})
|
||||||
opencode.expectExit(explicitlyDenied, 0)
|
opencode.expectExit(explicitlyDenied, 0)
|
||||||
@@ -277,6 +271,135 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
60_000,
|
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(
|
cliIt.live(
|
||||||
"attach mode sends client-local file contents without a shared path",
|
"attach mode sends client-local file contents without a shared path",
|
||||||
({ home, llm, opencode }) =>
|
({ home, llm, opencode }) =>
|
||||||
@@ -328,4 +451,19 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
}),
|
}),
|
||||||
30_000,
|
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 { 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 type { Resolved } from "@opencode-ai/tui/config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
|
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
|
||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
providerID,
|
providerID,
|
||||||
api: {
|
api: {
|
||||||
id: providerID,
|
id: providerID,
|
||||||
url: `https://${providerID}.test`,
|
type: "native" as const,
|
||||||
npm: `@ai-sdk/${providerID}`,
|
settings: {},
|
||||||
},
|
},
|
||||||
name: id,
|
name: id,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
temperature: true,
|
tools: true,
|
||||||
reasoning: true,
|
input: ["text"],
|
||||||
attachment: true,
|
output: ["text"],
|
||||||
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: {
|
request: {
|
||||||
input: 0,
|
headers: {},
|
||||||
output: 0,
|
body: {},
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
variants: variants.map((variant) => ({
|
||||||
|
id: variant,
|
||||||
|
headers: {},
|
||||||
|
body: {},
|
||||||
|
})),
|
||||||
|
time: {
|
||||||
|
released: 1,
|
||||||
|
},
|
||||||
|
cost: [
|
||||||
|
{
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
limit: {
|
limit: {
|
||||||
context,
|
context,
|
||||||
output: 8192,
|
output: 8192,
|
||||||
},
|
},
|
||||||
status: "active" as const,
|
status: "active" as const,
|
||||||
options: {},
|
enabled: true,
|
||||||
headers: {},
|
|
||||||
release_date: "2026-01-01",
|
|
||||||
variants,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,119 +173,101 @@ describe("run runtime boot", () => {
|
|||||||
await expect(resolveDiffStyle()).resolves.toBe("auto")
|
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 sdk = new OpencodeClient()
|
||||||
const data: {
|
const providers = [provider("openai", "OpenAI")]
|
||||||
all: Provider[]
|
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
|
||||||
default: Record<string, string>
|
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
|
||||||
connected: string[]
|
// @ts-expect-error successful SDK response is valid for both modes at runtime
|
||||||
} = {
|
const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
|
||||||
all: [
|
// @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",
|
id: "openai",
|
||||||
name: "OpenAI",
|
name: "OpenAI",
|
||||||
source: "api",
|
|
||||||
env: [],
|
|
||||||
options: {},
|
|
||||||
models: {
|
models: {
|
||||||
"gpt-5": model("gpt-5", "openai", 128000, {
|
"gpt-5": {
|
||||||
high: {},
|
id: "gpt-5",
|
||||||
minimal: {},
|
providerID: "openai",
|
||||||
}),
|
name: "gpt-5",
|
||||||
},
|
capabilities: {
|
||||||
},
|
tools: true,
|
||||||
{
|
input: ["text"],
|
||||||
id: "anthropic",
|
output: ["text"],
|
||||||
name: "Anthropic",
|
},
|
||||||
source: "api",
|
cost: {
|
||||||
env: [],
|
input: 0,
|
||||||
options: {},
|
output: 0,
|
||||||
models: {
|
cache: {
|
||||||
sonnet: model("sonnet", "anthropic", 200000),
|
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"],
|
variants: ["high", "minimal"],
|
||||||
limits: {
|
limits: {
|
||||||
"openai/gpt-5": 128000,
|
"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 sdk = new OpencodeClient()
|
||||||
const data: {
|
const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")]
|
||||||
all: Provider[]
|
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)]
|
||||||
default: Record<string, string>
|
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
|
||||||
connected: string[]
|
// @ts-expect-error successful SDK response is valid for both modes at runtime
|
||||||
} = {
|
spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
|
||||||
all: [
|
// @ts-expect-error successful SDK response is valid for both modes at runtime
|
||||||
{
|
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
|
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"],
|
variants: ["high", "minimal"],
|
||||||
limits: {
|
limits: {
|
||||||
"openai/gpt-5": 128000,
|
"openai/gpt-5": 128000,
|
||||||
|
|||||||
@@ -3,44 +3,18 @@ import { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|||||||
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
|
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
|
||||||
import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
|
import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
|
||||||
|
|
||||||
type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
|
|
||||||
|
|
||||||
const provider: RunProvider = {
|
const provider: RunProvider = {
|
||||||
id: "openai",
|
id: "openai",
|
||||||
name: "OpenAI",
|
name: "OpenAI",
|
||||||
source: "api",
|
|
||||||
env: [],
|
|
||||||
options: {},
|
|
||||||
models: {
|
models: {
|
||||||
"gpt-5": {
|
"gpt-5": {
|
||||||
id: "gpt-5",
|
id: "gpt-5",
|
||||||
providerID: "openai",
|
providerID: "openai",
|
||||||
api: {
|
|
||||||
id: "openai",
|
|
||||||
url: "https://openai.test",
|
|
||||||
npm: "@ai-sdk/openai",
|
|
||||||
},
|
|
||||||
name: "Little Frank",
|
name: "Little Frank",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
temperature: true,
|
tools: true,
|
||||||
reasoning: true,
|
input: ["text"],
|
||||||
attachment: true,
|
output: ["text"],
|
||||||
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: {
|
cost: {
|
||||||
input: 0,
|
input: 0,
|
||||||
@@ -55,9 +29,7 @@ const provider: RunProvider = {
|
|||||||
output: 8192,
|
output: 8192,
|
||||||
},
|
},
|
||||||
status: "active",
|
status: "active",
|
||||||
options: {},
|
variants: {},
|
||||||
headers: {},
|
|
||||||
release_date: "2026-01-01",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -141,44 +113,129 @@ describe("run interactive runtime", () => {
|
|||||||
const providers = defer<void>()
|
const providers = defer<void>()
|
||||||
|
|
||||||
const sdk = new OpencodeClient()
|
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()
|
providersStarted.resolve()
|
||||||
await providers.promise
|
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(() =>
|
spyOn(sdk.v2.model, "list").mockImplementation(() =>
|
||||||
ok([
|
ok({
|
||||||
{
|
location: {
|
||||||
info: {
|
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",
|
id: "msg-user-1",
|
||||||
sessionID: "ses-1",
|
type: "user",
|
||||||
role: "user",
|
text: "hello",
|
||||||
time: {
|
time: {
|
||||||
created: 1,
|
created: 1,
|
||||||
},
|
},
|
||||||
agent: "build",
|
},
|
||||||
model: {
|
],
|
||||||
providerID: "openai",
|
cursor: {},
|
||||||
modelID: "gpt-5",
|
}),
|
||||||
variant: undefined,
|
)
|
||||||
|
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: [
|
time: {
|
||||||
{
|
created: 1,
|
||||||
id: "part-user-1",
|
updated: 1,
|
||||||
sessionID: "ses-1",
|
},
|
||||||
messageID: "msg-user-1",
|
location: {
|
||||||
type: "text",
|
directory: "/tmp",
|
||||||
text: "hello",
|
},
|
||||||
},
|
model: {
|
||||||
],
|
providerID: "openai",
|
||||||
} satisfies SessionMessage,
|
id: "gpt-5",
|
||||||
]),
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
|
spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
|
spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
|
spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
spyOn(sdk.command, "list").mockImplementation(() => ok([]))
|
spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
|
|
||||||
const task = runInteractiveMode(
|
const task = runInteractiveMode(
|
||||||
{
|
{
|
||||||
@@ -215,6 +272,7 @@ describe("run interactive runtime", () => {
|
|||||||
}, 0)
|
}, 0)
|
||||||
return {
|
return {
|
||||||
runPromptTurn: async () => {},
|
runPromptTurn: async () => {},
|
||||||
|
interruptActiveTurn: async () => {},
|
||||||
selectSubagent: () => {},
|
selectSubagent: () => {},
|
||||||
replayOnResize: async () => false,
|
replayOnResize: async () => false,
|
||||||
close: async () => {},
|
close: async () => {},
|
||||||
@@ -234,5 +292,8 @@ describe("run interactive runtime", () => {
|
|||||||
await task
|
await task
|
||||||
|
|
||||||
expect(transportProviders).toEqual([[provider]])
|
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 { describe, expect, test } from "bun:test"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
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"
|
import type { StreamCommit } from "@/cli/cmd/run/types"
|
||||||
|
|
||||||
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
|
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", () => {
|
test("surfaces session errors as error commits", () => {
|
||||||
const out = reduce(createSessionData(), {
|
const out = reduce(createSessionData(), {
|
||||||
type: "session.error",
|
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 {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
|
resolveCurrentSession,
|
||||||
sessionHistory,
|
sessionHistory,
|
||||||
sessionVariant,
|
sessionVariant,
|
||||||
type RunSession,
|
type RunSession,
|
||||||
@@ -18,6 +20,10 @@ const model = {
|
|||||||
modelID: "gpt-5",
|
modelID: "gpt-5",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
|
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
|
||||||
return {
|
return {
|
||||||
info: {
|
info: {
|
||||||
@@ -244,4 +250,74 @@ describe("run session shared", () => {
|
|||||||
|
|
||||||
expect(sessionVariant(session, model)).toBe("minimal")
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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 path from "path"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { describe, expect, test } from "bun:test"
|
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 { Global } from "@opencode-ai/core/global"
|
||||||
import {
|
import {
|
||||||
createVariantRuntime,
|
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) {
|
function remap(root: string, file: string) {
|
||||||
if (file === Global.Path.state) {
|
if (file === Global.Path.state) {
|
||||||
@@ -124,7 +123,7 @@ function remappedFs(root: string) {
|
|||||||
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
|
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", () => {
|
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", () =>
|
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const filesys = yield* FileSystem.FileSystem
|
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const root = yield* filesys.makeTempDirectoryScoped()
|
const root = yield* fs.makeTempDirectoryScoped()
|
||||||
const file = path.join(root, "model.json")
|
const file = path.join(root, "model.json")
|
||||||
|
|
||||||
yield* fs.writeJson(file, {
|
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"))
|
yield* Effect.promise(() => svc.saveVariant(model, "high"))
|
||||||
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("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", () =>
|
it.live("repairs malformed saved variant state on the next write", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const filesys = yield* FileSystem.FileSystem
|
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const root = yield* filesys.makeTempDirectoryScoped()
|
const root = yield* fs.makeTempDirectoryScoped()
|
||||||
const file = path.join(root, "model.json")
|
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"))
|
yield* Effect.promise(() => svc.saveVariant(model, "high"))
|
||||||
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
|
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fs from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import yargs from "yargs"
|
import yargs from "yargs"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { MiniLocalCommand } from "../../../src/cli/cmd/mini"
|
||||||
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
|
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { cliIt } from "../../lib/cli-process"
|
||||||
|
|
||||||
@@ -45,12 +46,12 @@ describe("tui thread", () => {
|
|||||||
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
|
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"]) {
|
for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) {
|
||||||
const args = await yargs([])
|
const args = await yargs([])
|
||||||
.command({ ...TuiThreadCommand, handler: () => {} })
|
.command({ ...MiniLocalCommand, handler: () => {} })
|
||||||
.exitProcess(false)
|
.exitProcess(false)
|
||||||
.parse(["--mini", option, "--replay-limit", "10"])
|
.parse([option, "--replay-limit", "10"])
|
||||||
|
|
||||||
expect(args.replay === false || args.noReplay === true).toBe(true)
|
expect(args.replay === false || args.noReplay === true).toBe(true)
|
||||||
expect(args.replayLimit).toBe(10)
|
expect(args.replayLimit).toBe(10)
|
||||||
@@ -66,30 +67,48 @@ describe("tui thread", () => {
|
|||||||
expect(args.mdns).toBe(false)
|
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* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* opencode.spawn(["--replay-limit", "10"])
|
const result = yield* opencode.spawn(["--mini"])
|
||||||
|
|
||||||
opencode.expectExit(result, 1)
|
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* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
|
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
|
||||||
|
|
||||||
opencode.expectExit(result, 1)
|
opencode.expectExit(result, 1)
|
||||||
expect(result.stderr).toContain("--mini requires a TTY stdout")
|
expect(result.stderr).not.toContain("opencode 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")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
// argv parsing → server boot → SDK call → event consumption → exit code (like
|
// argv parsing → server boot → SDK call → event consumption → exit code (like
|
||||||
// the original /event race or #27371's invalid-model hang).
|
// the original /event race or #27371's invalid-model hang).
|
||||||
//
|
//
|
||||||
// Configuration flows through opencode's built-in test affordances:
|
// Configuration flows through an isolated global opencode.json under the temp home:
|
||||||
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
|
|
||||||
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
|
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
|
||||||
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
|
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
|
||||||
// - OPENCODE_PURE : skip external plugin discovery + install
|
// - 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 {
|
return {
|
||||||
OPENCODE_TEST_HOME: home,
|
OPENCODE_TEST_HOME: home,
|
||||||
|
PWD: home,
|
||||||
HOME: home,
|
HOME: home,
|
||||||
XDG_CONFIG_HOME: path.join(home, ".config"),
|
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||||
XDG_DATA_HOME: path.join(home, ".local/share"),
|
XDG_DATA_HOME: path.join(home, ".local/share"),
|
||||||
XDG_STATE_HOME: path.join(home, ".local/state"),
|
XDG_STATE_HOME: path.join(home, ".local/state"),
|
||||||
XDG_CACHE_HOME: path.join(home, ".cache"),
|
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_DISABLE_PROJECT_CONFIG: "1",
|
||||||
OPENCODE_PURE: "1",
|
OPENCODE_PURE: "1",
|
||||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||||
@@ -89,7 +89,11 @@ export type RunHandle = {
|
|||||||
readonly result: Effect.Effect<RunResult>
|
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
|
// Typed equivalent of constructing argv for `opencode run`. New flags should
|
||||||
// land here so tests stay grep-able and refactor-safe.
|
// 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),
|
.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)
|
||||||
const env = isolatedEnv(home, configJson)
|
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) {
|
const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) {
|
||||||
|
yield* writeConfig(opts?.config)
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const timeoutMs = opts?.timeoutMs ?? 30_000
|
const timeoutMs = opts?.timeoutMs ?? 30_000
|
||||||
// stdin: "ignore" so the child doesn't see a piped stdin and block
|
// 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
|
// consumed as the prompt). The old Process.run wrapper defaulted to
|
||||||
// ignore; ChildProcess.make defaults to pipe, so we set it explicitly.
|
// ignore; ChildProcess.make defaults to pipe, so we set it explicitly.
|
||||||
const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], {
|
const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], {
|
||||||
cwd: home,
|
cwd: opencodeRoot,
|
||||||
env: { ...env, ...opts?.env },
|
env: { ...env, ...opts?.env },
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
@@ -250,6 +259,7 @@ export function withCliFixture<A, E>(
|
|||||||
|
|
||||||
const runArgs = (message: string, opts?: RunOpts) => {
|
const runArgs = (message: string, opts?: RunOpts) => {
|
||||||
const argv: string[] = ["run"]
|
const argv: string[] = ["run"]
|
||||||
|
if (!opts?.extraArgs?.includes("--attach")) argv.push("--dir", home)
|
||||||
if (opts?.printLogs) argv.push("--print-logs")
|
if (opts?.printLogs) argv.push("--print-logs")
|
||||||
argv.push("--model", opts?.model ?? testModelID)
|
argv.push("--model", opts?.model ?? testModelID)
|
||||||
if (opts?.agent) argv.push("--agent", opts.agent)
|
if (opts?.agent) argv.push("--agent", opts.agent)
|
||||||
@@ -264,13 +274,8 @@ export function withCliFixture<A, E>(
|
|||||||
if (!opts?.permission) return opts
|
if (!opts?.permission) return opts
|
||||||
return {
|
return {
|
||||||
...opts,
|
...opts,
|
||||||
env: {
|
env: opts.env,
|
||||||
...opts.env,
|
config: { ...testProviderConfig(llm.url), permission: opts.permission },
|
||||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
||||||
...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 startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const options = runOpts(opts)
|
const options = runOpts(opts)
|
||||||
|
yield* writeConfig(options?.config)
|
||||||
const proc = yield* Effect.acquireRelease(
|
const proc = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
|
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
|
||||||
cwd: home,
|
cwd: opencodeRoot,
|
||||||
env: { ...process.env, ...env, ...options?.env },
|
env: { ...process.env, ...env, ...options?.env },
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
@@ -325,7 +331,7 @@ export function withCliFixture<A, E>(
|
|||||||
const proc = yield* Effect.acquireRelease(
|
const proc = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
||||||
cwd: home,
|
cwd: opencodeRoot,
|
||||||
env: { ...process.env, ...env, ...opts?.env },
|
env: { ...process.env, ...env, ...opts?.env },
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
@@ -396,7 +402,7 @@ export function withCliFixture<A, E>(
|
|||||||
const proc = yield* Effect.acquireRelease(
|
const proc = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
||||||
cwd: opts?.cwd ?? home,
|
cwd: opencodeRoot,
|
||||||
env: { ...process.env, ...env, ...opts?.env },
|
env: { ...process.env, ...env, ...opts?.env },
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function testProviderConfig(llmUrl: string) {
|
|||||||
options: {},
|
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/location", "v2.location.get").json(200, object),
|
||||||
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
|
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
|
||||||
http.protected.get("/api/model", "v2.model.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/provider", "v2.provider.list").json(200, locationData(array)),
|
||||||
http.protected.get("/api/integration", "v2.integration.list").json(200, locationData(array)),
|
http.protected.get("/api/integration", "v2.integration.list").json(200, locationData(array)),
|
||||||
http.protected
|
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 Endpoint6_0Output = EffectValue<ReturnType<RawClient["server.model"]["model.list"]>>
|
||||||
export type ModelListOperation<E = never> = (input?: Endpoint6_0Input) => Effect.Effect<Endpoint6_0Output, E>
|
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> {
|
export interface ModelApi<E = never> {
|
||||||
readonly list: ModelListOperation<E>
|
readonly list: ModelListOperation<E>
|
||||||
|
readonly default: ModelDefaultOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
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(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
title: "models",
|
title: "models",
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ export const PromptAdmitted = Event.define({
|
|||||||
})
|
})
|
||||||
export type PromptAdmitted = typeof PromptAdmitted.Type
|
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({
|
export const ContextUpdated = Event.define({
|
||||||
type: "session.next.context.updated",
|
type: "session.next.context.updated",
|
||||||
...options,
|
...options,
|
||||||
@@ -524,6 +534,7 @@ export const Definitions = Event.inventory(
|
|||||||
Forked,
|
Forked,
|
||||||
Prompted,
|
Prompted,
|
||||||
PromptAdmitted,
|
PromptAdmitted,
|
||||||
|
ExecutionSettled,
|
||||||
ContextUpdated,
|
ContextUpdated,
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Skill.Activated,
|
Skill.Activated,
|
||||||
|
|||||||
@@ -301,6 +301,8 @@ import type {
|
|||||||
V2LocationGetResponses,
|
V2LocationGetResponses,
|
||||||
V2McpListErrors,
|
V2McpListErrors,
|
||||||
V2McpListResponses,
|
V2McpListResponses,
|
||||||
|
V2ModelDefaultErrors,
|
||||||
|
V2ModelDefaultResponses,
|
||||||
V2ModelListErrors,
|
V2ModelListErrors,
|
||||||
V2ModelListResponses,
|
V2ModelListResponses,
|
||||||
V2PermissionRequestListErrors,
|
V2PermissionRequestListErrors,
|
||||||
@@ -6120,6 +6122,28 @@ export class Model extends HeyApiClient {
|
|||||||
...params,
|
...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 {
|
export class Generate extends HeyApiClient {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type Event =
|
|||||||
| EventSessionNextForked
|
| EventSessionNextForked
|
||||||
| EventSessionNextPrompted
|
| EventSessionNextPrompted
|
||||||
| EventSessionNextPromptAdmitted
|
| EventSessionNextPromptAdmitted
|
||||||
|
| EventSessionNextExecutionSettled
|
||||||
| EventSessionNextContextUpdated
|
| EventSessionNextContextUpdated
|
||||||
| EventSessionNextSynthetic
|
| EventSessionNextSynthetic
|
||||||
| EventSessionNextSkillActivated
|
| EventSessionNextSkillActivated
|
||||||
@@ -923,6 +924,16 @@ export type GlobalEvent = {
|
|||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "session.next.execution.settled"
|
||||||
|
properties: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
outcome: "success" | "failure" | "interrupted"
|
||||||
|
error?: SessionErrorUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "session.next.context.updated"
|
type: "session.next.context.updated"
|
||||||
@@ -3010,6 +3021,7 @@ export type V2Event =
|
|||||||
| SessionNextForked
|
| SessionNextForked
|
||||||
| SessionNextPrompted
|
| SessionNextPrompted
|
||||||
| SessionNextPromptAdmitted
|
| SessionNextPromptAdmitted
|
||||||
|
| SessionNextExecutionSettled
|
||||||
| SessionNextContextUpdated
|
| SessionNextContextUpdated
|
||||||
| SessionNextSynthetic
|
| SessionNextSynthetic
|
||||||
| SessionNextSkillActivated
|
| 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 = {
|
export type SessionNextTextDelta = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
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 = {
|
export type EventSessionNextContextUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "session.next.context.updated"
|
type: "session.next.context.updated"
|
||||||
@@ -12875,6 +12918,47 @@ export type V2ModelListResponses = {
|
|||||||
|
|
||||||
export type V2ModelListResponse = V2ModelListResponses[keyof 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 = {
|
export type V2GenerateTextData = {
|
||||||
body: {
|
body: {
|
||||||
prompt: string
|
prompt: string
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { response } from "../location"
|
|||||||
|
|
||||||
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
|
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return handlers.handle(
|
return handlers
|
||||||
"model.list",
|
.handle(
|
||||||
Effect.fn(function* () {
|
"model.list",
|
||||||
const catalog = yield* Catalog.Service
|
Effect.fn(function* () {
|
||||||
return yield* response(catalog.model.available())
|
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
|
# 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
|
## 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).
|
- 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).
|
||||||
|
|||||||
Reference in New Issue
Block a user