diff --git a/.opencode/skills/opencode-drive/SKILL.md b/.opencode/skills/opencode-drive/SKILL.md new file mode 100644 index 0000000000..6633526b10 --- /dev/null +++ b/.opencode/skills/opencode-drive/SKILL.md @@ -0,0 +1,254 @@ +--- +name: opencode-drive +description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance +--- + +# OpenCode Drive + +Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script. + +There are two modes. Always default to using a script unless specifically directed to be interactive (connect +to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate +on changes). + +Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits, +stops all processes, and cleans up all artifacts. + +# Prepare The Environment + +Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it. + +```bash +artifacts=$(opencode-drive init --name demo) +cp -R ./fixtures/home/. "$artifacts/" +cp -R ./fixtures/project/. "$artifacts/files/" +opencode-drive start --name demo --dev ~/projects/opencode +``` + +The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically. + +# Scripted usage + +You can write scripts that walk through entire flows, and gives you full access to controlling +the backend too. See examples of the script API at the bottom of this file. + +After creating or editing a script, always typecheck it before running. Never skip this step: + +```bash +opencode-drive check ./reproduce-stale-exploring-empty.ts +``` + +Run it by passing `--script` to start: + +```bash +opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts +``` + +It will output information about the run, including paths to log files which you can read +to inspect what happened. If you need to dig into failures that aren't clear, read those log +files. If the script is unsuccessful, automatically fix the script and run it again. + +Scripts use one typed definition object. `setup` runs before OpenCode starts, +and `fs.writeFile` always writes inside the simulated project. + +You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts + +```ts +import { defineScript } from "opencode-drive" + +export default defineScript({ + async setup({ fs, config }) { + config.autoupdate = false + await fs.writeFile("src/example.ts", "export const value = 1\n") + }, + + async run({ ui, llm }) { + await ui.submit("Open src/example.ts") + await llm.send(llm.text("The file exports `value`.")) + await ui.waitFor("The file exports `value`.") + }, +}) +``` + +`setup` receives the current OpenCode config object, which starts from the +default drive config unless the prepared instance already has one. When a script +needs custom config, mutate this `config` parameter instead of generating and +writing a new config object from scratch, so the script keeps the default +provider/model settings unless it intentionally changes them. + +Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files. + +Use `launch: "manual"` when the script needs to launch the server and every TUI +itself (this is extremely rare, do not use this unless explicitly asked). In this +mode `ui` is typed as `null`; call `server.launch()` exactly +once before launching clients. Each `clients.launch(name)` result provides the +same UI methods as the automatic client. You can see an example of this API +here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts + +Use the exported `wait(milliseconds)` utility for an unconditional delay. + +`await llm.send(...)` waits for the next request and resolves after OpenCode +acknowledges its complete response. `llm.queue(...)` declares responses in +advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`, +`finish`, and `disconnect`. A normal response receives `finish("stop")` +automatically unless it yields or queues an explicit terminal event. + +`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a +15-character target varied by plus or minus 5 per chunk. + +`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a +delay between any two outputs. + +Use `llm.serve` for an ongoing typed response generator: + +```ts +llm.serve(async function* (request, index) { + yield llm.reasoning(`Handling request ${index + 1}`) + yield llm.text(`Received ${request.id}`) + yield llm.finish("stop") +}) +``` + +The backend connection, response cleanup, cancellation, and recording +completion are automatic. + +You can see some example scripts here: + +- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts +- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts + +## Prune + +- `prune` removes artifact directories. These are always cleaned up after running a script + successfully, but leftover on failed runs. Always call this if a script fails. + +```bash +opencode-drive prune --name demo + +// --force cleans up all artifcat directories +opencode-dirve prune --force +``` + +# Live interaction usage + +- Always give headless instances a unique `--name`. Visible instances may omit it. +- A normal headless `start` detaches automatically and returns after the instance is ready. +- Do not add `&`; the long-running owner already runs in the background. +- Configure simulated model responses after startup when needed. +- Send ordered UI commands with `send`. +- Always stop the instance when finished. + +```bash +opencode-drive start --name demo + +opencode-drive send --name demo \ + --command.ui.type '{"text":"Explain this project"}' \ + --command.ui.enter + +opencode-drive stop --name demo +``` + +## Send UI Commands + +- Every `send` opens a connection to the named instance, runs its commands in order, and exits. +- Combine typing and Enter in one command when submitting a prompt. +- JSON-valued commands require one JSON argument. +- Multiple command flags execute from left to right. + +Commands: + +- `--command.ui.type ` types into the focused editor. Arguments: `text` string. +- `--command.ui.press ` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`. +- `--command.ui.enter` presses Enter. Arguments: none. +- `--command.ui.arrow ` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`. +- `--command.ui.focus ` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`. +- `--command.ui.click ` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`. +- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none. +- `--command.ui.matches ` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string. + +```bash +opencode-drive send --name demo \ + --command.ui.type '{"text":"Find the relevant code and explain it"}' \ + --command.ui.enter + +opencode-drive send --name demo \ + --command.ui.press '{"key":"p","modifiers":{"ctrl":true}}' + +opencode-drive send --name demo \ + --command.ui.arrow '{"direction":"down"}' + +opencode-drive send --name demo \ + --command.ui.focus '{"target":12}' + +opencode-drive send --name demo \ + --command.ui.click '{"target":12,"x":4,"y":1}' + +opencode-drive send --name demo \ + --command.ui.matches '{"text":"OpenCode"}' +``` + +To read the UI state and see information about interactable elements, use the `ui.state` command: + +```bash +opencode-drive send --name demo --command.ui.state +``` + +## Configure LLM Responses + +- `responses` controls what the LLM responds with +- Only use this if you are wanting to reproduce an exact type of response +- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`. +- Supported types are `text`, `reasoning`, `diff`, and `tool`. +- `--tools` limits generated tool calls to names offered by OpenCode. + +```bash +opencode-drive responses --name demo \ + --types text,reasoning,diff,tool \ + --tools write,apply_patch + +opencode-drive responses --name demo \ + --types tool \ + --tools read,glob,grep +``` + +## Inspect The UI + +- `ui.state` prints focus and interactive element metadata as JSON. +- `ui.matches` checks for literal, case-sensitive screen text. +- `screenshot` prints the generated image path. + +```bash +opencode-drive screenshot --name demo +``` + +## Lifecycle + +- `stop` waits for recording export and owner cleanup before returning. + +```bash +opencode-drive stop --name demo +``` + +# Record The UI + +- Start with `--record` to capture a headless instance from its first rendered frame. +- `stop` finishes the recording, exports an MP4, and prints its path. + +```bash +opencode-drive start --name demo --record + +opencode-drive send --name demo \ + --command.ui.type '{"text":"Show me the current architecture"}' \ + --command.ui.enter + +opencode-drive stop --name demo +``` + +# Artifacts dir + +- `dir` prints the artifact directory for the instance. + +```bash +opencode-drive dir --name demo +``` + diff --git a/packages/cli/bunfig.toml b/packages/cli/bunfig.toml index 7693482f3b..b16283cb5b 100644 --- a/packages/cli/bunfig.toml +++ b/packages/cli/bunfig.toml @@ -1 +1,4 @@ preload = ["@opentui/solid/preload"] + +[test] +preload = ["@opentui/solid/preload"] diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 78e50369f7..d7b5921628 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -2,12 +2,13 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { run } from "@opencode-ai/tui" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" -import { TuiConfig } from "@opencode-ai/tui/config" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" +import { TuiConfig } from "../../tui-config" import { Effect, Option } from "effect" import { Server } from "../../services/server" import { Updater } from "../../services/updater" +import { UpdatePreflight } from "../../services/update-preflight" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -15,17 +16,33 @@ export default Runtime.handler(Commands, (input) => if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.forkScoped) + const preflight = UpdatePreflight.make() + yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) const server = yield* Server.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, - }) - const config = TuiConfig.resolve({}, { terminalSuspend: false }) + onStart: (reason, existing) => { + if (reason === "version-mismatch" && preflight.begin(existing?.version)) return + process.stderr.write( + reason === "version-mismatch" + ? "Restarting background server (version mismatch)...\n" + : "Starting background server...\n", + ) + }, + }).pipe( + Effect.tapError(() => + Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")), + ), + ) + preflight.loading() + const config = yield* TuiConfig.load() let disposeSlots: (() => void) | undefined const runFork = Effect.runForkWith(yield* Effect.context()) yield* run({ server, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, config, + terminalHandoff: () => preflight.finish(), log: (level, message, tags) => { const effect = level === "debug" diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 4c6214d9a8..7d30159126 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -778,19 +778,26 @@ export function createPromptState(input: PromptInput): PromptState { if (!area || area.isDestroyed) return false const endOffset = Bun.stringWidth(area.plainText) - if (dir === -1 && area.visualCursor.visualRow === 0) { - area.cursorOffset = 0 + if (dir === -1) { + if (area.cursorOffset === 0) return false + if (area.visualCursor.visualRow === 0) { + area.cursorOffset = 0 + return + } + area.moveCursorUp() + return } const end = typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0 ? area.height - 1 : Math.max(0, (area.virtualLineCount ?? 1) - 1) - if (dir === 1 && area.visualCursor.visualRow === end) { + if (area.cursorOffset === endOffset) return false + if (area.visualCursor.visualRow === end) { area.cursorOffset = endOffset + return } - - return false + area.moveCursorDown() } const requestExit = () => { @@ -1037,6 +1044,7 @@ export function createPromptState(input: PromptInput): PromptState { })) useBindings(() => ({ + priority: 1, mode: OPENCODE_BASE_MODE, enabled: input.prompt() && !visible(), commands: [ diff --git a/packages/cli/src/mini/runtime.boot.ts b/packages/cli/src/mini/runtime.boot.ts index b5fca98dc0..5739bc7e9a 100644 --- a/packages/cli/src/mini/runtime.boot.ts +++ b/packages/cli/src/mini/runtime.boot.ts @@ -6,7 +6,7 @@ // history ring. All are async because they read config or hit the SDK, but // none block each other. import { Context, Effect, Layer } from "effect" -import { resolve } from "@opencode-ai/tui/config" +import { resolve } from "@opencode-ai/tui/config/v1" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeRuntime } from "@opencode-ai/core/effect/runtime" diff --git a/packages/cli/src/mini/types.ts b/packages/cli/src/mini/types.ts index 0e3b4bea3b..caa90fb512 100644 --- a/packages/cli/src/mini/types.ts +++ b/packages/cli/src/mini/types.ts @@ -13,7 +13,7 @@ // → OpenTUI split-footer renderer writes to terminal import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise" import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" -import type { TuiConfig } from "@opencode-ai/tui/config" +import type { TuiConfig } from "@opencode-ai/tui/config/v1" export type RunFilePart = { type: "file" diff --git a/packages/cli/src/services/server.ts b/packages/cli/src/services/server.ts index 5ea8434792..34ddcf965d 100644 --- a/packages/cli/src/services/server.ts +++ b/packages/cli/src/services/server.ts @@ -11,6 +11,7 @@ export type Args = { readonly server?: string readonly standalone?: boolean readonly mismatch?: "replace" | "ignore" | "error" + readonly onStart?: Service.StartOptions["onStart"] } export type Resolved = { @@ -46,7 +47,7 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { } const options = yield* ServiceConfig.options() - const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace") + const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace") const reconnectOptions = { ...options, version: undefined } return { endpoint, @@ -70,7 +71,7 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { }) const resolveManaged = Effect.fnUntraced(function* ( - options: Service.Options, + options: Service.StartOptions, mismatch: NonNullable, ) { if (mismatch === "replace") return yield* Service.start(options) diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx new file mode 100644 index 0000000000..e471e798b0 --- /dev/null +++ b/packages/cli/src/services/update-preflight.tsx @@ -0,0 +1,494 @@ +/** @jsxImportSource @opentui/solid */ +// Split-footer status shown while a freshly launched CLI replaces a +// version-mismatched background service before the TUI attaches. +import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core" +import { render, useTerminalDimensions } from "@opentui/solid" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" +import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" +import { go } from "@opencode-ai/tui/logo" +import { + batch, + createEffect, + createMemo, + createSignal, + For, + Index, + on, + onCleanup, + onMount, + Show, + untrack, +} from "solid-js" + +const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"] as const +const stageFloor = 480 +const transitionDuration = 420 +const completionHold = 650 + +export type Handle = { + readonly begin: (from?: string) => boolean + readonly loading: () => void + readonly finish: () => Promise + readonly fail: (message: string) => Promise + readonly close: () => Promise +} + +export type Handoff = { + readonly renderer: CliRenderer + readonly mode: ThemeMode | null + readonly complete: () => void +} + +export const make = (): Handle => { + let session: Promise | undefined + return { + begin: (from) => { + if (!process.stdout.isTTY || !process.stdin.isTTY) return false + session ??= open(from).catch(() => { + process.stderr.write("Restarting background server (version mismatch)...\n") + return undefined + }) + return true + }, + loading: () => { + void session?.then((active) => active?.loading()) + }, + finish: async () => { + const active = await session + return active?.finish() + }, + fail: async (message) => { + const active = await session + await active?.fail(message) + }, + close: async () => { + const active = await session + await active?.close() + }, + } +} + +type Session = { + readonly loading: () => Promise + readonly finish: () => Promise + readonly fail: (message: string) => Promise + readonly close: () => Promise +} + +async function open(from?: string): Promise { + registerOpencodeSpinner() + const [active, setActive] = createSignal(0) + const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running") + const [failure, setFailure] = createSignal("") + const [animating, setAnimating] = createSignal(true) + const [visible, setVisible] = createSignal(true) + let resolveOutcome: (() => void) | undefined + const renderer = await createCliRenderer({ + stdin: process.stdin, + useMouse: false, + autoFocus: false, + openConsoleOnError: false, + exitOnCtrlC: false, + screenMode: "split-footer", + footerHeight: 4, + targetFps: 60, + useKittyKeyboard: {}, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + }, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + }) + const terminalMode = renderer.waitForThemeMode(1000).catch(() => null) + await render( + () => ( + + resolveOutcome?.()} + /> + + ), + renderer, + ).catch((error) => { + if (!renderer.isDestroyed) renderer.destroy() + throw error + }) + let shownAt = performance.now() + const waitForStage = async () => { + const remaining = stageFloor - (performance.now() - shownAt) + if (remaining > 0) await Bun.sleep(remaining) + } + const advance = async (stage: number) => { + await waitForStage() + if (outcome() !== "running") return + setActive(stage) + shownAt = performance.now() + } + // Service.start currently exposes only its start boundary, so this first + // transition is time-based. Finer lifecycle callbacks remain follow-up work. + const auto = advance(1) + const transitionTo = async (next: "success" | "failure", hold: number) => { + const settled = Promise.withResolvers() + resolveOutcome = settled.resolve + setOutcome(next) + const completed = await Promise.race([ + settled.promise.then(() => true), + Bun.sleep(transitionDuration + 500).then(() => false), + ]) + resolveOutcome = undefined + setAnimating(false) + if (completed) await Bun.sleep(hold) + } + let closing: Promise | undefined + let transferred = false + const close = () => + (closing ??= (async () => { + if (transferred) return + setAnimating(false) + if (renderer.isDestroyed) return + renderer.pause() + await Promise.race([renderer.idle(), Bun.sleep(500)]) + renderer.destroy() + })()) + let loading: Promise | undefined + const load = () => + (loading ??= (async () => { + await auto + await advance(2) + })()) + let settled: Promise | undefined + const settle = (task: () => Promise) => (settled ??= task()) + return { + loading: load, + finish: async () => { + await settle(async () => { + await load() + await waitForStage() + await transitionTo("success", completionHold) + }) + const mode = await terminalMode + renderer.externalOutputMode = "passthrough" + renderer.screenMode = "alternate-screen" + renderer.consoleMode = "console-overlay" + renderer.requestRender() + await Promise.race([renderer.idle(), Bun.sleep(500)]) + transferred = true + return { + renderer, + mode, + complete: () => setVisible(false), + } + }, + fail: (message) => + settle(async () => { + setFailure(message) + await transitionTo("failure", 250) + await close() + }), + close, + } +} + +const colors = { + accent: RGBA.fromHex("#a6b8ff"), + accentBright: RGBA.fromHex("#eef1ff"), + accentDim: RGBA.fromHex("#596998"), + error: RGBA.fromHex("#ff8192"), + muted: RGBA.fromHex("#808080"), + success: RGBA.fromHex("#8bd5a5"), + text: RGBA.fromHex("#eeeeee"), +} + +const monogram = go.right.slice(1) +const sweepBlend = 8 +const textDim = RGBA.fromHex("#4c4c4c") +const rampSteps = 32 + +const blend = (from: RGBA, to: RGBA, amount: number) => + RGBA.fromValues( + from.r + (to.r - from.r) * amount, + from.g + (to.g - from.g) * amount, + from.b + (to.b - from.b) * amount, + ) +const ramp = (from: RGBA, to: RGBA) => + Array.from({ length: rampSteps + 1 }, (_, step) => blend(from, to, step / rampSteps)) +const railRamp = ramp(colors.accentDim, colors.accentBright) +const monogramRamp = ramp(colors.muted, colors.accent) +const rampCache = new Map>() +const rampFor = (color: RGBA) => { + const cached = rampCache.get(color) + if (cached) return cached + const result = ramp(textDim, color) + rampCache.set(color, result) + return result +} +const shade = (palette: ReadonlyArray, brightness: number) => + palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)] + +type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean } +const styled = (text: string, color: RGBA, bold?: boolean): Cell[] => + Array.from(text).map((char) => ({ char, color, bold })) +const phrase = (...segments: ReadonlyArray): Cell[] => + segments.flatMap((segment, index) => [ + ...(index > 0 ? styled(" ", colors.muted) : []), + ...styled(segment[0], segment[1], segment[2]), + ]) + +function Monogram(props: { ink: () => RGBA }) { + const shadow = createMemo(() => { + const ink = props.ink() + return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25) + }) + return ( + + + {(line) => ( + + + {(char) => + char === "_" ? ( + + {" "} + + ) : ( + + {char} + + ) + } + + + )} + + + ) +} + +type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void } + +function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) { + const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>() + const [progress, setProgress] = createSignal(0) + let elapsed = 0 + const cells = createMemo(() => { + const transition = state() + if (!transition) return undefined + return render(transition, progress()) + }) + return { + start(from: Cell[], to: Cell[], done?: () => void) { + elapsed = 0 + setProgress(0) + setState({ from, to, done }) + }, + tick(deltaTime: number) { + const transition = state() + if (!transition) return + elapsed = Math.min(transitionDuration, elapsed + deltaTime) + setProgress(elapsed / transitionDuration) + if (elapsed < transitionDuration) return + setState(undefined) + transition.done?.() + }, + cells, + progress, + } +} + +const createSweep = () => + createTransition((transition, progress) => { + const length = Math.max(transition.from.length, transition.to.length) + const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend + return Array.from({ length }, (_, index) => { + const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend)) + const brightness = smoothstep(Math.abs(passed * 2 - 1)) + const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? { + char: " ", + color: colors.text, + } + return { ...cell, color: shade(rampFor(cell.color), brightness) } + }) + }) + +const createFade = () => + createTransition((transition, progress) => { + const entering = progress >= 0.5 + const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2) + return (entering ? transition.to : transition.from).map((cell) => ({ + ...cell, + color: shade(rampFor(cell.color), brightness), + })) + }) + +const smoothstep = (value: number) => value * value * (3 - 2 * value) +const frameDone = Promise.resolve() + +function UpdateFooter(props: { + from?: string + active: () => number + outcome: () => "running" | "success" | "failure" + failure: () => string + animating: () => boolean + renderer: CliRenderer + onOutcomeSettled: () => void +}) { + const term = useTerminalDimensions() + const [position, setPosition] = createSignal(0) + const [pulse, setPulse] = createSignal(0) + const headerFade = createFade() + const statusSweep = createSweep() + const runningHeader = () => + phrase( + ["OpenCode", colors.muted, true], + ["is updating", colors.muted], + ...(props.from + ? ([ + ["from", colors.muted], + [props.from, colors.accentDim], + ] as const) + : []), + ["to", colors.muted], + [InstallationVersion, colors.accent], + ) + const completedHeader = phrase( + ["OpenCode", colors.muted, true], + ["updated to", colors.muted], + [InstallationVersion, colors.accent], + ) + const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted]) + const outcomeStatus = () => + props.outcome() === "success" + ? [...styled("✓", colors.success), ...styled(" Ready", colors.text)] + : [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)] + let previousStage: string = stages[0] + createEffect( + on(props.active, (index) => { + if (props.outcome() !== "running") return + const next = stages[index] + if (next === previousStage) return + statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text)) + previousStage = next + }), + ) + createEffect( + on( + props.outcome, + (outcome) => { + if (outcome === "running") return + const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text) + headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader) + statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled) + }, + { defer: true }, + ), + ) + const header = createMemo( + () => + headerFade.cells() ?? + (props.outcome() === "success" + ? completedHeader + : props.outcome() === "failure" + ? pausedHeader + : runningHeader()), + ) + const monogramInk = createMemo(() => + props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted, + ) + const rail = createMemo(() => { + const width = Math.max(0, Math.min(30, term().width - 39)) + if (width === 0) return [] + const filled = Math.round(position() * width) + const glowRadius = 6 + const span = Math.max(1, filled + glowRadius * 2) + const center = pulse() * span - glowRadius + const success = props.outcome() === "success" + const completion = smoothstep(headerFade.progress()) + return Array.from({ length: width }, (_, index) => { + const color = + index >= filled + ? colors.muted + : shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2) + return { + char: success || index < filled ? "━" : "·", + color: success ? blend(color, colors.accent, completion) : color, + } + }) + }) + + onMount(() => { + let value = 0 + let velocity = 0 + let phase = 0 + const frame = (deltaTime: number) => { + if (!props.animating()) return frameDone + const elapsed = Math.min(0.032, deltaTime / 1_000) + const stiffness = 110 + const damping = 2 * Math.sqrt(stiffness) + const target = props.outcome() === "success" ? 1 : (props.active() + 1) / stages.length + velocity += (stiffness * (target - value) - damping * velocity) * elapsed + value += velocity * elapsed + phase = (phase + deltaTime / 900) % 1 + batch(() => { + setPosition(Math.max(0, Math.min(1, value))) + setPulse(phase) + }) + headerFade.tick(deltaTime) + statusSweep.tick(deltaTime) + return frameDone + } + props.renderer.setFrameCallback(frame) + onCleanup(() => props.renderer.removeFrameCallback(frame)) + }) + + return ( + + + + + } + > + + + + + + + + + {props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length} + + + + + ) +} + +function CellLine(props: { cells: ReadonlyArray }) { + return ( + + + {(cell) => ( + + {cell().char} + + )} + + + ) +} + +export * as UpdatePreflight from "./update-preflight" diff --git a/packages/cli/src/tui-config.ts b/packages/cli/src/tui-config.ts new file mode 100644 index 0000000000..1b838917bb --- /dev/null +++ b/packages/cli/src/tui-config.ts @@ -0,0 +1,24 @@ +export * as TuiConfig from "./tui-config" + +import { Global } from "@opencode-ai/core/global" +import { TuiConfig } from "@opencode-ai/tui/config/v1" +import { Effect, FileSystem, Option, Schema } from "effect" +import { parse, type ParseError } from "jsonc-parser" +import path from "path" + +export const load = Effect.fn("TuiConfig.load")(function* () { + const fs = yield* FileSystem.FileSystem + const global = yield* Global.Service + const filepath = path.join(global.config, "tui.json") + const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" }) + + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" }) + + return TuiConfig.resolve( + Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})), + { terminalSuspend: process.platform !== "win32" }, + ) +}) diff --git a/packages/cli/test/footer-keymap.test.tsx b/packages/cli/test/footer-keymap.test.tsx new file mode 100644 index 0000000000..1aa0e3270e --- /dev/null +++ b/packages/cli/test/footer-keymap.test.tsx @@ -0,0 +1,106 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap" +import { resolve } from "@opencode-ai/tui/config/v1" +import { expect, test } from "bun:test" +import { createComponent, createSignal } from "solid-js" +import { RunFooterView } from "../src/mini/footer.view" +import { RUN_THEME_FALLBACK } from "../src/mini/theme" +import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types" + +test("down opens subagents from an empty prompt", async () => { + const [state] = createSignal({ + phase: "idle", + status: "", + queue: 0, + model: "gpt-5", + duration: "", + usage: "", + first: false, + interrupt: 0, + exit: 0, + }) + const [view] = createSignal({ type: "prompt" }) + const [subagents] = createSignal({ + tabs: [ + { + sessionID: "subagent-1", + partID: "part-1", + callID: "call-1", + label: "Explore", + description: "Inspect the keymap", + status: "running", + lastUpdatedAt: 1, + }, + ], + details: {}, + permissions: [], + questions: [], + }) + const config = resolve( + { keybinds: { editor_open: "none", session_queued_prompts: "none" } }, + { terminalSuspend: true }, + ) + let offKeymap: (() => void) | undefined + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + offKeymap = registerOpencodeKeymap(keymap, renderer, config) + + return createComponent(OpencodeKeymapProvider, { + keymap, + get children() { + return ( + []} + agents={() => []} + references={() => []} + commands={() => []} + providers={() => undefined} + currentModel={() => undefined} + variants={() => []} + currentVariant={() => undefined} + state={state} + view={view} + subagent={subagents} + theme={() => RUN_THEME_FALLBACK} + tuiConfig={config} + agent="opencode" + onSubmit={() => true} + onPermissionReply={() => {}} + onQuestionReply={() => {}} + onQuestionReject={() => {}} + onCycle={() => {}} + onInterrupt={() => false} + onEditorOpen={async () => undefined} + onInputClear={() => {}} + onExit={() => {}} + onModelSelect={() => {}} + onVariantSelect={() => {}} + onRows={() => {}} + onLayout={() => {}} + onStatus={() => {}} + onQueuedRemove={async () => true} + /> + ) + }, + }) + } + + const app = await testRender(() => , { width: 100, height: 8, kittyKeyboard: true }) + try { + await app.renderOnce() + expect(app.renderer.currentFocusedEditor?.plainText).toBe("") + app.mockInput.pressArrow("down") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Select subagent") + } finally { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + offKeymap?.() + app.renderer.destroy() + } +}) diff --git a/packages/cli/test/tui-config.test.ts b/packages/cli/test/tui-config.test.ts new file mode 100644 index 0000000000..a9656c4fae --- /dev/null +++ b/packages/cli/test/tui-config.test.ts @@ -0,0 +1,25 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/core/global" +import { Effect } from "effect" +import { expect, test } from "bun:test" +import path from "path" +import { TuiConfig } from "../src/tui-config" + +test("loads the global tui config", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } })) + + try { + const config = await Effect.runPromise( + TuiConfig.load().pipe( + Effect.provide(Global.layerWith({ config: directory })), + Effect.provide(NodeFileSystem.layer), + ), + ) + + expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o") + expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") + } finally { + await Bun.$`rm -rf ${directory}` + } +}) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 334c4b1f18..28c06cbf04 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -476,18 +476,16 @@ export interface IntegrationApi { type Endpoint11_0Request = Parameters[0] export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } export type Endpoint11_0Output = EffectValue> -export type ServerMcpListOperation = (input?: Endpoint11_0Input) => Effect.Effect +export type McpListOperation = (input?: Endpoint11_0Input) => Effect.Effect type Endpoint11_1Request = Parameters[0] export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] } export type Endpoint11_1Output = EffectValue> -export type ServerMcpResourceCatalogOperation = ( - input?: Endpoint11_1Input, -) => Effect.Effect +export type McpResourceCatalogOperation = (input?: Endpoint11_1Input) => Effect.Effect -export interface ServerMcpApi { - readonly list: ServerMcpListOperation - readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation } +export interface McpApi { + readonly list: McpListOperation + readonly resource: { readonly catalog: McpResourceCatalogOperation } } type Endpoint12_0Request = Parameters[0] @@ -955,7 +953,7 @@ export interface AppApi { readonly generate: GenerateApi readonly provider: ProviderApi readonly integration: IntegrationApi - readonly "server.mcp": ServerMcpApi + readonly mcp: McpApi readonly credential: CredentialApi readonly project: ProjectApi readonly form: FormApi diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 7e14e5151f..1f58717113 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1134,7 +1134,7 @@ const adaptClient = (raw: RawClient) => ({ generate: adaptGroup8(raw["server.generate"]), provider: adaptGroup9(raw["server.provider"]), integration: adaptGroup10(raw["server.integration"]), - "server.mcp": adaptGroup11(raw["server.mcp"]), + mcp: adaptGroup11(raw["server.mcp"]), credential: adaptGroup12(raw["server.credential"]), project: adaptGroup13(raw["server.project"]), form: adaptGroup14(raw["server.form"]), diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 3e5d3ec0f2..f047d462bf 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -32,6 +32,15 @@ export type Options = { readonly command?: ReadonlyArray } +export type StartReason = "missing" | "version-mismatch" + +export type StartOptions = Options & { + // Called once when start() decides it must spawn: either no service was + // found, or a healthy service with a different version is being replaced. + // `existing` carries the registration of the service being replaced. + readonly onStart?: (reason: StartReason, existing?: Info) => void +} + // Read-only lookup: registration file plus health check and version gate. // Never spawns; escalation to start() is the caller's policy. export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { @@ -47,10 +56,13 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) { // Idempotent ensure-running: reuses a healthy compatible server, replaces a // version-mismatched one, and otherwise spawns the service command detached. -export const start = Effect.fn("service.start")(function* (options: Options = {}) { +export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) { const compatible = yield* discover(options) if (compatible !== undefined) return compatible const mismatched = yield* find(options) + yield* Effect.sync(() => + options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info), + ) if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore) const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index d31a1881fa..b4bc8635b2 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -1,43 +1,15 @@ -import type { - AgentApi as EffectAgentApi, - CommandApi as EffectCommandApi, - EventApi as EffectEventApi, - IntegrationApi as EffectIntegrationApi, - ModelApi as EffectModelApi, - PluginApi as EffectPluginApi, - ProviderApi as EffectProviderApi, - ReferenceApi as EffectReferenceApi, - SessionApi as EffectSessionApi, - SkillApi as EffectSkillApi, -} from "../effect/api/api.js" -import type { Effect, Stream } from "effect" +type Client = ReturnType -type PromisifyOperation = Operation extends ( - ...args: infer Args -) => Effect.Effect - ? (...args: Args) => Promise - : Operation extends (...args: infer Args) => Stream.Stream - ? (...args: Args) => AsyncIterable - : Operation extends (...args: infer _Args) => unknown - ? Operation - : Operation extends object - ? PromisifyApi - : Operation - -type PromisifyApi = { - readonly [Name in keyof Api]: PromisifyOperation -} - -export type AgentApi = PromisifyApi> -export type CommandApi = PromisifyApi> -export type EventApi = PromisifyApi> -export type IntegrationApi = PromisifyApi> -export type ModelApi = PromisifyApi> -export type PluginApi = PromisifyApi> -export type ProviderApi = PromisifyApi> -export type ReferenceApi = PromisifyApi> -export type SessionApi = PromisifyApi> -export type SkillApi = PromisifyApi> +export type AgentApi = Client["agent"] +export type CommandApi = Client["command"] +export type EventApi = Client["event"] +export type IntegrationApi = Client["integration"] +export type ModelApi = Client["model"] +export type PluginApi = Client["plugin"] +export type ProviderApi = Client["provider"] +export type ReferenceApi = Client["reference"] +export type SessionApi = Client["session"] +export type SkillApi = Client["skill"] export interface CatalogApi { readonly provider: ProviderApi diff --git a/packages/client/src/promise/generated/client-error.ts b/packages/client/src/promise/generated/client-error.ts index c278f0ddc8..930b612383 100644 --- a/packages/client/src/promise/generated/client-error.ts +++ b/packages/client/src/promise/generated/client-error.ts @@ -1,4 +1,9 @@ -export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" +export type ClientErrorReason = + | "Transport" + | "UnexpectedStatus" + | "UnsupportedContentType" + | "MalformedResponse" + | "SseEventTooLarge" export class ClientError extends Error { override readonly name = "ClientError" diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 28c223e6b5..bed325d976 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -90,10 +90,10 @@ import type { IntegrationAttemptCompleteOutput, IntegrationAttemptCancelInput, IntegrationAttemptCancelOutput, - ServerMcpListInput, - ServerMcpListOutput, - ServerMcpResourceCatalogInput, - ServerMcpResourceCatalogOutput, + McpListInput, + McpListOutput, + McpResourceCatalogInput, + McpResourceCatalogOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -193,12 +193,12 @@ import { ClientError } from "./client-error" export interface ClientOptions { readonly baseUrl: string readonly fetch?: typeof globalThis.fetch - readonly headers?: HeadersInit + readonly headers?: RequestInit["headers"] } export interface RequestOptions { readonly signal?: AbortSignal - readonly headers?: HeadersInit + readonly headers?: RequestInit["headers"] } interface RequestDescriptor { @@ -213,6 +213,8 @@ interface RequestDescriptor { readonly binary?: true } +const maxSseEventBytes = 16 * 1024 * 1024 + export function make(options: ClientOptions) { const fetch = options.fetch ?? globalThis.fetch @@ -289,7 +291,7 @@ export function make(options: ClientOptions) { throw new ClientError("Transport", { cause }) } buffer += decoder.decode(next.value, { stream: !next.done }) - if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse") + if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge") const trailingCarriageReturn = !next.done && buffer.endsWith("\r") if (trailingCarriageReturn) buffer = buffer.slice(0, -1) buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n") @@ -939,9 +941,9 @@ export function make(options: ClientOptions) { ), }, }, - "server.mcp": { - list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => - request( + mcp: { + list: (input?: McpListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/mcp`, @@ -953,8 +955,8 @@ export function make(options: ClientOptions) { requestOptions, ), resource: { - catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) => - request( + catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/mcp/resource`, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index c6a703c5fa..255beaa1bb 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1,5 +1,2327 @@ export type JsonValue = null | boolean | number | string | Array | { [key: string]: JsonValue } +export type ModelRef = { id: string; providerID: string; variant?: string } + +export type ProviderSettings = { [x: string]: JsonValue } + +export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + +export type PermissionV2Effect = "allow" | "deny" | "ask" + +export type PluginInfo = { id: string } + +export type MoneyUSD = number + +export type TokenUsageInfo = { + input: number + output: number + reasoning: number + cache: { read: number; write: number } +} + +export type LocationRef = { directory: string; workspaceID?: string } + +export type FileDiffInfo = { + file: string + patch: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +export type SessionActive = { type: "running" } + +export type PromptBase64 = string + +export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string } + +export type PromptMention = { start: number; end: number; text: string } + +export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } } + +export type SessionPendingCompaction = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + type: "compaction" +} + +export type SessionMessageAgentSelected = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + type: "agent-switched" + agent: string +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + text: string + description?: string + type: "synthetic" +} + +export type SessionMessageSystem = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + type: "system" + text: string +} + +export type SessionMessageSkill = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + type: "skill" + skill: string + name: string + text: string +} + +export type SessionMessageShell = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number; completed?: number } + type: "shell" + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "Infinity" | "-Infinity" | "NaN" + output?: { output: string; cursor: number; size: number; truncated: boolean } +} + +export type SessionMessageAssistantText = { type: "text"; text: string } + +export type SessionMessageProviderState = { [x: string]: JsonValue } + +export type SessionMessageToolStateStreaming = { status: "streaming"; input: string } + +export type ToolTextContent = { type: "text"; text: string } + +export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string } + +export type SessionStructuredError = { type: string; message: string } + +export type SessionMessageCompactionRunning = { + type: "compaction" + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type InstructionEntryKey = string + +export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } } + +export type ShellInfo = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { [x: string]: any } + time: { started: number; completed?: number } +} + +export type SessionMessageProviderState3 = { [x: string]: any } + +export type SessionMessageProviderState4 = { [x: string]: any } + +export type SessionMessageProviderState5 = { [x: string]: any } + +export type SessionMessageProviderState6 = { [x: string]: any } + +export type SessionMessageProviderState7 = { [x: string]: any } + +export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number } + +export type ModelCapabilities = { tools: boolean; input: Array; output: Array } + +export type ModelVariant = { + id: string + settings?: { [x: string]: JsonValue } + headers?: { [x: string]: string } + body?: { [x: string]: JsonValue } +} + +export type MoneyUSDPerMillionTokens = number + +export type GenerateTextResponse = { data: { text: string } } + +export type ProviderV2Info = { + id: string + integrationID?: string + name: string + disabled?: boolean + package: string + settings?: { [x: string]: JsonValue } + headers?: { [x: string]: string } + body?: { [x: string]: JsonValue } +} + +export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string } + +export type IntegrationKeyMethod = { type: "key"; label?: string } + +export type IntegrationEnvMethod = { type: "env"; names: Array } + +export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string } + +export type ConnectionEnvInfo = { type: "env"; name: string } + +export type IntegrationAttemptStatus = + | { + status: "pending" + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + | { + status: "complete" + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + | { + status: "failed" + message: string + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + | { + status: "expired" + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + +export type McpStatusConnected = { status: "connected" } + +export type McpStatusPending = { status: "pending" } + +export type McpStatusDisabled = { status: "disabled" } + +export type McpStatusFailed = { status: "failed"; error: string } + +export type McpStatusNeedsAuth = { status: "needs_auth" } + +export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string } + +export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string } + +export type McpResourceTemplate = { + server: string + name: string + uriTemplate: string + description?: string + mimeType?: string +} + +export type ProjectVcs = "git" | "hg" + +export type ProjectIcon = { url?: string; override?: string; color?: string } + +export type ProjectCommands = { start?: string } + +export type ProjectTime = { created: number; updated: number; initialized?: number } + +export type ProjectCurrent = { id: string; directory: string } + +export type ProjectDirectory = { directory: string; strategy?: string } + +export type FormMetadata = { [x: string]: JsonValue } + +export type FormWhen = { + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean +} + +export type FormOption = { value: string; label: string; description?: string } + +export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string } + +export type FormValue = string | number | boolean | Array + +export type PermissionV2Source = { type: "tool"; messageID: string; callID: string } + +export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string } + +export type FileSystemEntry = { path: string; type: "file" | "directory" } + +export type SkillInfo = { + id: string + name: string + description?: string + slash?: boolean + autoinvoke?: boolean + location: string + content: string +} + +export type FileDiffLegacyInfo = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type PermissionAction = "allow" | "deny" | "ask" + +export type JSONSchema = { [x: string]: any } + +export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } } + +export type UnknownError2 = { name: "UnknownError"; data: { message: string; ref?: string | undefined } } + +export type MessageOutputLengthError = { name: "MessageOutputLengthError"; data: {} } + +export type MessageAbortedError = { name: "MessageAbortedError"; data: { message: string } } + +export type StructuredOutputError = { name: "StructuredOutputError"; data: { message: string; retries: number } } + +export type ContextOverflowError = { + name: "ContextOverflowError" + data: { message: string; responseBody?: string | undefined } +} + +export type ContentFilterError = { name: "ContentFilterError"; data: { message: string } } + +export type APIError = { + name: "APIError" + data: { + message: string + statusCode?: number | undefined + isRetryable: boolean + responseHeaders?: { [x: string]: string } | undefined + responseBody?: string | undefined + metadata?: { [x: string]: string } | undefined + } +} + +export type TextPart = { + id: string + sessionID: string + messageID: string + type: "text" + text: string + synthetic?: boolean | undefined + ignored?: boolean | undefined + time?: { start: number; end?: number | undefined } | undefined + metadata?: { [x: string]: any } | undefined +} + +export type SubtaskPart = { + id: string + sessionID: string + messageID: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { providerID: string; modelID: string } | undefined + command?: string | undefined +} + +export type ReasoningPart = { + id: string + sessionID: string + messageID: string + type: "reasoning" + text: string + metadata?: { [x: string]: any } | undefined + time: { start: number; end?: number | undefined } +} + +export type FilePartSourceText = { value: string; start: number; end: number } + +export type Range = { start: { line: number; character: number }; end: { line: number; character: number } } + +export type ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string } + +export type ToolStateRunning = { + status: "running" + input: { [x: string]: any } + title?: string | undefined + metadata?: { [x: string]: any } | undefined + time: { start: number } +} + +export type ToolStateError = { + status: "error" + input: { [x: string]: any } + error: string + metadata?: { [x: string]: any } | undefined + time: { start: number; end: number } +} + +export type StepStartPart = { + id: string + sessionID: string + messageID: string + type: "step-start" + snapshot?: string | undefined +} + +export type StepFinishPart = { + id: string + sessionID: string + messageID: string + type: "step-finish" + reason: string + snapshot?: string | undefined + cost: number + tokens: { + total?: number | undefined + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } +} + +export type SnapshotPart = { id: string; sessionID: string; messageID: string; type: "snapshot"; snapshot: string } + +export type PatchPart = { + id: string + sessionID: string + messageID: string + type: "patch" + hash: string + files: Array +} + +export type AgentPart = { + id: string + sessionID: string + messageID: string + type: "agent" + name: string + source?: { value: string; start: number; end: number } | undefined +} + +export type CompactionPart = { + id: string + sessionID: string + messageID: string + type: "compaction" + auto: boolean + overflow?: boolean | undefined + tail_start_id?: string | undefined +} + +export type PermissionV2Reply = "once" | "always" | "reject" + +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + +export type QuestionV2Option = { label: string; description: string } + +export type QuestionV2Tool = { messageID: string; callID: string } + +export type QuestionV2Answer = Array + +export type FormMetadata1 = { [x: string]: any } + +export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean } + +export type SessionStatus = + | { type: "idle" } + | { + type: "retry" + attempt: number + message: string + action?: { reason: string; provider: string; title: string; message: string; label: string; link?: string } + next: number + } + | { type: "busy" } + +export type QuestionOption = { label: string; description: string } + +export type QuestionTool = { messageID: string; callID: string } + +export type QuestionAnswer = Array + +export type ShellInfo1 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { [x: string]: JsonValue } + time: { started: number; completed?: number } +} + +export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean } + +export type ReferenceGitSource = { + type: "git" + repository: string + branch?: string + description?: string + hidden?: boolean +} + +export type ProjectCopyCopy = { directory: string } + +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +export type SessionMessageModelSelected = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + type: "model-switched" + model: ModelRef + previous?: ModelRef +} + +export type CommandInfo = { + name: string + template: string + description?: string + agent?: string + model?: ModelRef + subtask?: boolean +} + +export type ProviderRequest = { + settings: ProviderSettings + headers: { [x: string]: string } + body: { [x: string]: JsonValue } +} + +export type PermissionV2Rule = { action: string; resource: string; effect: PermissionV2Effect } + +export type SessionAgentSelected = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.agent.selected" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; agent: string } +} + +export type SessionModelSelected = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.model.selected" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; model: ModelRef } +} + +export type SessionMoved = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.moved" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; location: LocationRef; subpath?: string } +} + +export type SessionRenamed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.renamed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; title: string } +} + +export type SessionDeleted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.deleted" + durable: { aggregateID: string; seq: number; version: 2 } + location?: LocationRef + data: { sessionID: string } +} + +export type SessionForked = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.forked" + durable: { aggregateID: string; seq: number; version: 2 } + location?: LocationRef + data: { sessionID: string; parentID: string; parentSeq: number; from?: string } +} + +export type SessionInputPromoted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.input.promoted" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; inputID: string } +} + +export type SessionExecutionStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.execution.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string } +} + +export type SessionExecutionSucceeded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.execution.succeeded" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string } +} + +export type SessionExecutionInterrupted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.execution.interrupted" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; reason: "user" | "shutdown" | "superseded" } +} + +export type SessionInstructionsUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.instructions.updated" + durable: { aggregateID: string; seq: number; version: 2 } + location?: LocationRef + data: { sessionID: string; delta: { [x: string]: string | "removed" } } +} + +export type SessionSynthetic = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.synthetic" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: any } } +} + +export type SessionSkillActivated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.skill.activated" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; id: string; name: string; text: string } +} + +export type SessionStepStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.step.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string } +} + +export type SessionStepEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.step.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: MoneyUSD + tokens: TokenUsageInfo + snapshot?: string + files?: Array + } +} + +export type SessionTextStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.text.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; ordinal: number } +} + +export type SessionTextEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.text.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string } +} + +export type SessionToolInputStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.input.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; callID: string; name: string } +} + +export type SessionToolInputEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.input.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; callID: string; text: string } +} + +export type SessionCompactionAdmitted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.admitted" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; inputID: string } +} + +export type SessionCompactionStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } +} + +export type SessionCompactionEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string } +} + +export type SessionRevertCleared = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.revert.cleared" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string } +} + +export type SessionRevertCommitted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.revert.committed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; to: string } +} + +export type ModelsDevRefreshed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "models-dev.refreshed" + location?: LocationRef + data: {} +} + +export type IntegrationUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "integration.updated" + location?: LocationRef + data: {} +} + +export type IntegrationConnectionUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "integration.connection.updated" + location?: LocationRef + data: { integrationID: string } +} + +export type CatalogUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "catalog.updated" + location?: LocationRef + data: {} +} + +export type AgentUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "agent.updated" + location?: LocationRef + data: {} +} + +export type MessageRemoved = { + id: string + created: number + metadata?: { [x: string]: any } + type: "message.removed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; messageID: string } +} + +export type MessagePartRemoved = { + id: string + created: number + metadata?: { [x: string]: any } + type: "message.part.removed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; messageID: string; partID: string } +} + +export type SessionUsageUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.usage.updated" + location?: LocationRef + data: { sessionID: string; cost: MoneyUSD; tokens: TokenUsageInfo } +} + +export type SessionTextDelta = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.text.delta" + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string } +} + +export type SessionReasoningDelta = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.reasoning.delta" + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string } +} + +export type SessionToolInputDelta = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.input.delta" + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; callID: string; delta: string } +} + +export type SessionCompactionDelta = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.delta" + location?: LocationRef + data: { sessionID: string; text: string } +} + +export type FilesystemChanged = { + id: string + created: number + metadata?: { [x: string]: any } + type: "filesystem.changed" + location?: LocationRef + data: { file: string; event: "add" | "change" | "unlink" } +} + +export type ReferenceUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "reference.updated" + location?: LocationRef + data: {} +} + +export type PluginAdded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "plugin.added" + location?: LocationRef + data: { id: string } +} + +export type PluginUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "plugin.updated" + location?: LocationRef + data: {} +} + +export type ProjectDirectoriesUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "project.directories.updated" + location?: LocationRef + data: { projectID: string } +} + +export type CommandUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "command.updated" + location?: LocationRef + data: {} +} + +export type ConfigUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "config.updated" + location?: LocationRef + data: {} +} + +export type SkillUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "skill.updated" + location?: LocationRef + data: {} +} + +export type PtyExited = { + id: string + created: number + metadata?: { [x: string]: any } + type: "pty.exited" + location?: LocationRef + data: { id: string; exitCode: number } +} + +export type PtyDeleted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "pty.deleted" + location?: LocationRef + data: { id: string } +} + +export type ShellExited = { + id: string + created: number + metadata?: { [x: string]: any } + type: "shell.exited" + location?: LocationRef + data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" } +} + +export type ShellDeleted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "shell.deleted" + location?: LocationRef + data: { id: string } +} + +export type QuestionV2Rejected = { + id: string + created: number + metadata?: { [x: string]: any } + type: "question.v2.rejected" + location?: LocationRef + data: { sessionID: string; requestID: string } +} + +export type FormCancelled = { + id: string + created: number + metadata?: { [x: string]: any } + type: "form.cancelled" + location?: LocationRef + data: { id: string; sessionID: string } +} + +export type SessionIdle = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.idle" + location?: LocationRef + data: { sessionID: string } +} + +export type TuiPromptAppend = { + id: string + created: number + metadata?: { [x: string]: any } + type: "tui.prompt.append" + location?: LocationRef + data: { text: string } +} + +export type TuiCommandExecute = { + id: string + created: number + metadata?: { [x: string]: any } + type: "tui.command.execute" + location?: LocationRef + data: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.background" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type TuiToastShow = { + id: string + created: number + metadata?: { [x: string]: any } + type: "tui.toast.show" + location?: LocationRef + data: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number | undefined + } +} + +export type TuiSessionSelect = { + id: string + created: number + metadata?: { [x: string]: any } + type: "tui.session.select" + location?: LocationRef + data: { sessionID: string } +} + +export type InstallationUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "installation.updated" + location?: LocationRef + data: { version: string } +} + +export type InstallationUpdateAvailable = { + id: string + created: number + metadata?: { [x: string]: any } + type: "installation.update-available" + location?: LocationRef + data: { version: string } +} + +export type VcsBranchUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "vcs.branch.updated" + location?: LocationRef + data: { branch?: string } +} + +export type McpStatusChanged = { + id: string + created: number + metadata?: { [x: string]: any } + type: "mcp.status.changed" + location?: LocationRef + data: { server: string } +} + +export type McpResourcesChanged = { + id: string + created: number + metadata?: { [x: string]: any } + type: "mcp.resources.changed" + location?: LocationRef + data: { server: string } +} + +export type PermissionAsked = { + id: string + created: number + metadata?: { [x: string]: any } + type: "permission.asked" + location?: LocationRef + data: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { [x: string]: any } + always: Array + tool?: { messageID: string; callID: string } | undefined + } +} + +export type PermissionReplied = { + id: string + created: number + metadata?: { [x: string]: any } + type: "permission.replied" + location?: LocationRef + data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" } +} + +export type QuestionRejected = { + id: string + created: number + metadata?: { [x: string]: any } + type: "question.rejected" + location?: LocationRef + data: { sessionID: string; requestID: string } +} + +export type V2EventServerConnected = { + id: string + metadata?: { [x: string]: any } | undefined + location?: LocationRef | undefined + type: "server.connected" + data: {} +} + +export type SessionRevert = { messageID: string; partID?: string; snapshot?: string; files?: Array } + +export type PromptFileAttachment = { + data: PromptBase64 + mime: string + source: PromptFileSource + name?: string + description?: string + mention?: PromptMention +} + +export type PromptAgentAttachment = { name: string; mention?: PromptMention } + +export type SessionPendingSynthetic = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + type: "synthetic" + data: SessionPendingSyntheticData + delivery: "steer" | "queue" +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + text: string + state?: SessionMessageProviderState + time?: { created: number; completed?: number } +} + +export type LLMToolContent = ToolTextContent | ToolFileContent + +export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError } + +export type SessionMessageCompactionFailed = { + type: "compaction" + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError +} + +export type SessionExecutionFailed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.execution.failed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; error: SessionStructuredError } +} + +export type SessionStepFailed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.step.failed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + error: SessionStructuredError + cost?: MoneyUSD + tokens?: TokenUsageInfo + } +} + +export type SessionRetryScheduled = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.retry.scheduled" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; attempt: number; at: number; error: SessionStructuredError } +} + +export type SessionCompactionFailed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.failed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string } +} + +export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue } + +export type SessionPendingSyntheticMessage = { + type: "synthetic" + data: SessionPendingSyntheticData1 + delivery: "steer" | "queue" +} + +export type SessionShellStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.shell.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; shell: ShellInfo } +} + +export type SessionShellEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.shell.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + shell: ShellInfo + output: { output: string; cursor: number; size: number; truncated: boolean } + } +} + +export type ShellCreated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "shell.created" + location?: LocationRef + data: { info: ShellInfo } +} + +export type SessionReasoningStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.reasoning.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 } +} + +export type SessionReasoningEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.reasoning.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + ordinal: number + text: string + state?: SessionMessageProviderState4 + } +} + +export type SessionToolCalled = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.called" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + input: { [x: string]: any } + executed: boolean + state?: SessionMessageProviderState5 + } +} + +export type SessionToolFailed = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.failed" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + error: SessionStructuredError + result?: any + executed: boolean + resultState?: SessionMessageProviderState7 + } +} + +export type ModelCost = { + tier?: { type: "context"; size: number } + input: MoneyUSDPerMillionTokens + output: MoneyUSDPerMillionTokens + cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens } +} + +export type IntegrationTextPrompt = { + type: "text" + key: string + message: string + placeholder?: string + when?: IntegrationWhen +} + +export type IntegrationSelectPrompt = { + type: "select" + key: string + message: string + options: Array<{ label: string; value: string; hint?: string }> + when?: IntegrationWhen +} + +export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo + +export type McpServer = { + name: string + status: + | McpStatusConnected + | McpStatusPending + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + integrationID?: string +} + +export type McpResourceCatalog = { resources: Array; templates: Array } + +export type Project = { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array +} + +export type ProjectDirectories = Array + +export type FormNumberField = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" +} + +export type FormIntegerField = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" +} + +export type FormBooleanField = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "boolean" + default?: boolean +} + +export type FormStringField = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array + custom?: boolean +} + +export type FormMultiselectField = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "multiselect" + options: Array + minItems?: number + maxItems?: number + custom?: boolean + default?: Array +} + +export type FormAnswer = { [x: string]: FormValue } + +export type PermissionV2Request = { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { [x: string]: JsonValue } + source?: PermissionV2Source +} + +export type PermissionV2Asked = { + id: string + created: number + metadata?: { [x: string]: any } + type: "permission.v2.asked" + location?: LocationRef + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { [x: string]: any } + source?: PermissionV2Source + } +} + +export type PermissionRule = { permission: string; pattern: string; action: PermissionAction } + +export type OutputFormat = + | { type: "text" } + | { type: "json_schema"; schema: JSONSchema; retryCount?: number | undefined | undefined } + +export type AssistantMessage = { + id: string + sessionID: string + role: "assistant" + time: { created: number; completed?: number | undefined } + error?: + | ProviderAuthError + | UnknownError2 + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | APIError + | undefined + parentID: string + modelID: string + providerID: string + mode: string + agent: string + path: { cwd: string; root: string } + summary?: boolean | undefined + cost: number + tokens: { + total?: number | undefined + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } + structured?: any | undefined + variant?: string | undefined + finish?: string | undefined +} + +export type RetryPart = { + id: string + sessionID: string + messageID: string + type: "retry" + attempt: number + error: APIError + time: { created: number } +} + +export type SessionError = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.error" + location?: LocationRef + data: { + sessionID?: string | undefined + error?: + | ProviderAuthError + | UnknownError2 + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | APIError + | undefined + } +} + +export type FileSource = { text: FilePartSourceText; type: "file"; path: string } + +export type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string } + +export type SymbolSource = { + text: FilePartSourceText + type: "symbol" + path: string + range: Range + name: string + kind: number +} + +export type PermissionV2Replied = { + id: string + created: number + metadata?: { [x: string]: any } + type: "permission.v2.replied" + location?: LocationRef + data: { sessionID: string; requestID: string; reply: PermissionV2Reply } +} + +export type PtyCreated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "pty.created" + location?: LocationRef + data: { info: Pty } +} + +export type PtyUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "pty.updated" + location?: LocationRef + data: { info: Pty } +} + +export type QuestionV2Info = { + question: string + header: string + options: Array + multiple?: boolean + custom?: boolean +} + +export type QuestionV2Replied = { + id: string + created: number + metadata?: { [x: string]: any } + type: "question.v2.replied" + location?: LocationRef + data: { sessionID: string; requestID: string; answers: Array } +} + +export type FormStringField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array + custom?: boolean +} + +export type FormNumberField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "number" + minimum?: number + maximum?: number + default?: number +} + +export type FormIntegerField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "integer" + minimum?: number + maximum?: number + default?: number +} + +export type FormBooleanField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "boolean" + default?: boolean +} + +export type FormMultiselectField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "multiselect" + options: Array + minItems?: number + maxItems?: number + custom?: boolean + default?: Array +} + +export type SessionStatus2 = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.status" + location?: LocationRef + data: { sessionID: string; status: SessionStatus } +} + +export type QuestionInfo = { + question: string + header: string + options: Array + multiple?: boolean | undefined + custom?: boolean | undefined +} + +export type QuestionReplied = { + id: string + created: number + metadata?: { [x: string]: any } + type: "question.replied" + location?: LocationRef + data: { sessionID: string; requestID: string; answers: Array } +} + +export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource + +export type PermissionV2Ruleset = Array + +export type SessionInfo = { + id: string + parentID?: string + fork?: { sessionID: string; messageID?: string } + projectID: string + agent?: string + model?: ModelRef + cost: MoneyUSD + tokens: TokenUsageInfo + time: { created: number; updated: number; archived?: number } + title: string + location: LocationRef + subpath?: string + revert?: SessionRevert +} + +export type SessionRevertStaged = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.revert.staged" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; revert: SessionRevert } +} + +export type SessionPendingUserData = { + text: string + files?: Array + agents?: Array + metadata?: { [x: string]: JsonValue } +} + +export type SessionMessageUser = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionPendingUserData1 = { + text: string + files?: Array + agents?: Array + metadata?: { [x: string]: any } +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { [x: string]: JsonValue } + structured: { [x: string]: JsonValue } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { [x: string]: JsonValue } + content: Array + structured: { [x: string]: JsonValue } + result?: JsonValue +} + +export type SessionMessageToolStateError = { + status: "error" + input: { [x: string]: JsonValue } + content: Array + structured: { [x: string]: JsonValue } + error: SessionStructuredError + result?: JsonValue +} + +export type SessionToolProgress = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.progress" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { [x: string]: any } + content: Array + } +} + +export type SessionToolSuccess = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.success" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { [x: string]: any } + content: Array + result?: any + executed: boolean + resultState?: SessionMessageProviderState6 + } +} + +export type SessionMessageCompaction = + | SessionMessageCompactionRunning + | SessionMessageCompactionCompleted + | SessionMessageCompactionFailed + +export type ModelInfo = { + id: string + modelID: string + providerID: string + family?: string + name: string + package?: string + settings?: { [x: string]: JsonValue } + headers?: { [x: string]: string } + body?: { [x: string]: JsonValue } + capabilities: ModelCapabilities + variants: Array + time: { released: number } + cost: Array + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { context: number; input?: number; output: number } +} + +export type IntegrationOAuthMethod = { + id: string + type: "oauth" + label: string + prompts?: Array +} + +export type FormField = + | FormStringField + | FormNumberField + | FormIntegerField + | FormBooleanField + | FormMultiselectField + | FormExternalField + +export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" } + +export type FormReplied = { + id: string + created: number + metadata?: { [x: string]: any } + type: "form.replied" + location?: LocationRef + data: { id: string; sessionID: string; answer: FormAnswer } +} + +export type PermissionRuleset = Array + +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { created: number } + format?: OutputFormat | undefined + summary?: { title?: string | undefined; body?: string | undefined; diffs: Array } | undefined + agent: string + model: { providerID: string; modelID: string; variant?: string | undefined } + system?: string | undefined + tools?: { [x: string]: boolean } | undefined +} + +export type FilePartSource = FileSource | SymbolSource | ResourceSource + +export type QuestionV2Asked = { + id: string + created: number + metadata?: { [x: string]: any } + type: "question.v2.asked" + location?: LocationRef + data: { id: string; sessionID: string; questions: Array; tool?: QuestionV2Tool } +} + +export type QuestionV2Request = { + id: string + sessionID: string + questions: Array + tool?: QuestionV2Tool +} + +export type FormField1 = + | FormStringField1 + | FormNumberField1 + | FormIntegerField1 + | FormBooleanField1 + | FormMultiselectField1 + | FormExternalField + +export type QuestionAsked = { + id: string + created: number + metadata?: { [x: string]: any } + type: "question.asked" + location?: LocationRef + data: { id: string; sessionID: string; questions: Array; tool?: QuestionTool | undefined } +} + +export type ReferenceInfo = { + name: string + path: string + description?: string + hidden?: boolean + source: ReferenceSource +} + +export type AgentInfo = { + id: string + name: string + model?: ModelRef + request: ProviderRequest + system?: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean + color?: AgentColor + steps?: number + permissions: PermissionV2Ruleset +} + +export type SessionsResponse = { data: Array; cursor: { previous?: string | null; next?: string | null } } + +export type SessionPendingUser = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + type: "user" + data: SessionPendingUserData + delivery: "steer" | "queue" +} + +export type SessionPendingUserMessage = { type: "user"; data: SessionPendingUserData1; delivery: "steer" | "queue" } + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + executed?: boolean + providerState?: SessionMessageProviderState + providerResultState?: SessionMessageProviderState + state: + | SessionMessageToolStateStreaming + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { created: number; ran?: number; completed?: number } +} + +export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod + +export type FormFields = [FormField, ...Array] + +export type SessionV1Info = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { additions: number; deletions: number; files: number; diffs?: Array } + cost?: number + tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + share?: { url: string } + title: string + agent?: string + model?: { id: string; providerID: string; variant?: string } + version: string + metadata?: { [x: string]: any } + time: { created: number; updated: number; compacting?: number; archived?: number } + permission?: PermissionRuleset + revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string } +} + +export type Message = UserMessage | AssistantMessage + +export type FilePart = { + id: string + sessionID: string + messageID: string + type: "file" + mime: string + filename?: string | undefined + url: string + source?: FilePartSource | undefined +} + +export type FormFields1 = [FormField1, ...Array] + +export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction + +export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage + +export type SessionMessageAssistant = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number; completed?: number } + type: "assistant" + agent: string + model: ModelRef + content: Array + snapshot?: { start?: string; end?: string; files?: Array } + finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost?: MoneyUSD + tokens?: TokenUsageInfo + error?: SessionStructuredError + retry?: SessionMessageAssistantRetry +} + +export type IntegrationInfo = { + id: string + name: string + methods: Array + connections: Array +} + +export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields } + +export type SessionCreated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.created" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; info: SessionV1Info } +} + +export type SessionUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.updated" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; info: SessionV1Info } +} + +export type SessionDeleted1 = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.deleted" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; info: SessionV1Info } +} + +export type MessageUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "message.updated" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; info: Message } +} + +export type ToolStateCompleted = { + status: "completed" + input: { [x: string]: any } + output: string + title: string + metadata: { [x: string]: any } + time: { start: number; end: number; compacted?: number | undefined } + attachments?: Array | undefined +} + +export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 } + +export type SessionInputAdmitted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.input.admitted" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; inputID: string; input: SessionPendingMessage } +} + +export type SessionMessageInfo = + | SessionMessageAgentSelected + | SessionMessageModelSelected + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageSystem + | SessionMessageSkill + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export type FormCreated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "form.created" + location?: LocationRef + data: { form: FormInfo1 } +} + +export type SessionEventDurable = + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionDeleted + | SessionForked + | SessionInputPromoted + | SessionInputAdmitted + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted + | SessionInstructionsUpdated + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetryScheduled + | SessionCompactionAdmitted + | SessionCompactionStarted + | SessionCompactionEnded + | SessionCompactionFailed + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted + +export type SessionMessagesResponse = { + data: Array + cursor: { previous?: string | null; next?: string | null } +} + +export type ToolPart = { + id: string + sessionID: string + messageID: string + type: "tool" + callID: string + tool: string + state: ToolState + metadata?: { [x: string]: any } | undefined +} + +export type SessionLogItem = SessionEventDurable | EventLogSynced + +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +export type MessagePartUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "message.part.updated" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; part: Part; time: number } +} + +export type V2Event = + | ModelsDevRefreshed + | IntegrationUpdated + | IntegrationConnectionUpdated + | CatalogUpdated + | AgentUpdated + | SessionCreated + | SessionUpdated + | SessionDeleted1 + | MessageUpdated + | MessageRemoved + | MessagePartUpdated + | MessagePartRemoved + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionUsageUpdated + | SessionDeleted + | SessionForked + | SessionInputPromoted + | SessionInputAdmitted + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted + | SessionInstructionsUpdated + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextDelta + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningDelta + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputDelta + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetryScheduled + | SessionCompactionAdmitted + | SessionCompactionStarted + | SessionCompactionDelta + | SessionCompactionEnded + | SessionCompactionFailed + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted + | FilesystemChanged + | ReferenceUpdated + | PermissionV2Asked + | PermissionV2Replied + | PluginAdded + | PluginUpdated + | ProjectDirectoriesUpdated + | CommandUpdated + | ConfigUpdated + | SkillUpdated + | PtyCreated + | PtyUpdated + | PtyExited + | PtyDeleted + | ShellCreated + | ShellExited + | ShellDeleted + | QuestionV2Asked + | QuestionV2Replied + | QuestionV2Rejected + | FormCreated + | FormReplied + | FormCancelled + | SessionStatus2 + | SessionIdle + | TuiPromptAppend + | TuiCommandExecute + | TuiToastShow + | TuiSessionSelect + | InstallationUpdated + | InstallationUpdateAvailable + | VcsBranchUpdated + | McpStatusChanged + | McpResourcesChanged + | PermissionAsked + | PermissionReplied + | QuestionAsked + | QuestionReplied + | QuestionRejected + | SessionError + | V2EventServerConnected + export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" @@ -181,23 +2503,7 @@ export type AgentListInput = { export type AgentListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - name: string - model?: { id: string; providerID: string; variant?: string } - request: { - settings: { [x: string]: JsonValue } - headers: { [x: string]: string } - body: { [x: string]: JsonValue } - } - system?: string - description?: string - mode: "subagent" | "primary" | "all" - hidden: boolean - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - steps?: number - permissions: Array<{ action: string; resource: string; effect: "allow" | "deny" | "ask" }> - }> + data: Array } export type PluginListInput = { @@ -208,7 +2514,7 @@ export type PluginListInput = { export type PluginListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ id: string }> + data: Array } export type SessionListInput = { @@ -313,35 +2619,7 @@ export type SessionListInput = { }["cursor"] } -export type SessionListOutput = { - data: Array<{ - id: string - parentID?: string - fork?: { sessionID: string; messageID?: string } - projectID: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - time: { created: number; updated: number; archived?: number } - title: string - location: { directory: string; workspaceID?: string } - subpath?: string - revert?: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } - }> - cursor: { previous?: string | null; next?: string | null } -} +export type SessionListOutput = SessionsResponse export type SessionCreateInput = { readonly id?: { @@ -370,67 +2648,13 @@ export type SessionCreateInput = { }["location"] } -export type SessionCreateOutput = { - data: { - id: string - parentID?: string - fork?: { sessionID: string; messageID?: string } - projectID: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - time: { created: number; updated: number; archived?: number } - title: string - location: { directory: string; workspaceID?: string } - subpath?: string - revert?: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } - } -}["data"] +export type SessionCreateOutput = { data: SessionInfo }["data"] -export type SessionActiveOutput = { data: { [x: string]: { type: "running" } } }["data"] +export type SessionActiveOutput = { data: { [x: string]: SessionActive } }["data"] export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionGetOutput = { - data: { - id: string - parentID?: string - fork?: { sessionID: string; messageID?: string } - projectID: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - time: { created: number; updated: number; archived?: number } - title: string - location: { directory: string; workspaceID?: string } - subpath?: string - revert?: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } - } -}["data"] +export type SessionGetOutput = { data: SessionInfo }["data"] export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } @@ -441,34 +2665,7 @@ export type SessionForkInput = { readonly messageID?: { readonly messageID?: string | undefined }["messageID"] } -export type SessionForkOutput = { - data: { - id: string - parentID?: string - fork?: { sessionID: string; messageID?: string } - projectID: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - time: { created: number; updated: number; archived?: number } - title: string - location: { directory: string; workspaceID?: string } - subpath?: string - revert?: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } - } -}["data"] +export type SessionForkOutput = { data: SessionInfo }["data"] export type SessionSwitchAgentInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -630,29 +2827,7 @@ export type SessionPromptInput = { }["resume"] } -export type SessionPromptOutput = { - data: { - admittedSeq: number - id: string - sessionID: string - timeCreated: number - type: "user" - data: { - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - metadata?: { [x: string]: JsonValue } - } - delivery: "steer" | "queue" - } -}["data"] +export type SessionPromptOutput = { data: SessionPendingUser }["data"] export type SessionCommandInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -829,29 +3004,7 @@ export type SessionCommandInput = { }["resume"] } -export type SessionCommandOutput = { - data: { - admittedSeq: number - id: string - sessionID: string - timeCreated: number - type: "user" - data: { - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - metadata?: { [x: string]: JsonValue } - } - delivery: "steer" | "queue" - } -}["data"] +export type SessionCommandOutput = { data: SessionPendingUser }["data"] export type SessionSkillInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -926,17 +3079,7 @@ export type SessionSyntheticInput = { }["resume"] } -export type SessionSyntheticOutput = { - data: { - admittedSeq: number - id: string - sessionID: string - timeCreated: number - type: "synthetic" - data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } } - delivery: "steer" | "queue" - } -}["data"] +export type SessionSyntheticOutput = { data: SessionPendingSynthetic }["data"] export type SessionShellInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -951,9 +3094,7 @@ export type SessionCompactInput = { readonly id?: { readonly id?: string | undefined }["id"] } -export type SessionCompactOutput = { - data: { admittedSeq: number; id: string; sessionID: string; timeCreated: number; type: "compaction" } -}["data"] +export type SessionCompactOutput = { data: SessionPendingCompaction }["data"] export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } @@ -965,20 +3106,7 @@ export type SessionRevertStageInput = { readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] } -export type SessionRevertStageOutput = { - data: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } -}["data"] +export type SessionRevertStageOutput = { data: SessionRevert }["data"] export type SessionRevertClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } @@ -990,204 +3118,15 @@ export type SessionRevertCommitOutput = void export type SessionContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionContextOutput = { - data: Array< - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "agent-switched" - agent: string - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "model-switched" - model: { id: string; providerID: string; variant?: string } - previous?: { id: string; providerID: string; variant?: string } - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - type: "user" - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - text: string - description?: string - type: "synthetic" - } - | { id: string; metadata?: { [x: string]: JsonValue }; time: { created: number }; type: "system"; text: string } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "skill" - skill: string - name: string - text: string - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number; completed?: number } - type: "shell" - shellID: string - command: string - status: "running" | "exited" | "timeout" | "killed" - exit?: number | "Infinity" | "-Infinity" | "NaN" - output?: { output: string; cursor: number; size: number; truncated: boolean } - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number; completed?: number } - type: "assistant" - agent: string - model: { id: string; providerID: string; variant?: string } - content: Array< - | { type: "text"; text: string } - | { - type: "reasoning" - text: string - state?: { [x: string]: JsonValue } - time?: { created: number; completed?: number } - } - | { - type: "tool" - id: string - name: string - executed?: boolean - providerState?: { [x: string]: JsonValue } - providerResultState?: { [x: string]: JsonValue } - state: - | { status: "streaming"; input: string } - | { - status: "running" - input: { [x: string]: JsonValue } - structured: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - } - | { - status: "completed" - input: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - structured: { [x: string]: JsonValue } - result?: JsonValue - } - | { - status: "error" - input: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - structured: { [x: string]: JsonValue } - error: { type: string; message: string } - result?: JsonValue - } - time: { created: number; ran?: number; completed?: number } - } - > - snapshot?: { start?: string; end?: string; files?: Array } - finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - error?: { type: string; message: string } - retry?: { attempt: number; at: number; error: { type: string; message: string } } - } - | ( - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "running" - reason: "auto" | "manual" - summary: string - recent: string - } - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "completed" - reason: "auto" | "manual" - summary: string - recent: string - } - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "failed" - reason: "auto" | "manual" - error: { type: string; message: string } - } - ) - > -}["data"] +export type SessionContextOutput = { data: Array }["data"] export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionPendingListOutput = { - data: Array< - | { - admittedSeq: number - id: string - sessionID: string - timeCreated: number - type: "user" - data: { - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - metadata?: { [x: string]: JsonValue } - } - delivery: "steer" | "queue" - } - | { - admittedSeq: number - id: string - sessionID: string - timeCreated: number - type: "synthetic" - data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } } - delivery: "steer" | "queue" - } - | { admittedSeq: number; id: string; sessionID: string; timeCreated: number; type: "compaction" } - > -}["data"] +export type SessionPendingListOutput = { data: Array }["data"] export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionInstructionsEntryListOutput = { data: Array<{ key: string; value: JsonValue }> }["data"] +export type SessionInstructionsEntryListOutput = { data: Array }["data"] export type SessionInstructionsEntryPutInput = { readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"] @@ -1210,488 +3149,7 @@ export type SessionLogInput = { readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"] } -export type SessionLogOutput = - | ( - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.agent.selected" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; agent: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.model.selected" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; model: { id: string; providerID: string; variant?: string } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.moved" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; location: { directory: string; workspaceID?: string }; subpath?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.renamed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; title: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.deleted" - durable: { aggregateID: string; seq: number; version: 2 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.forked" - durable: { aggregateID: string; seq: number; version: 2 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; parentID: string; parentSeq: number; from?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.input.promoted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; inputID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.input.admitted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - inputID: string - input: - | { - type: "user" - data: { - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - metadata?: { [x: string]: unknown } - } - delivery: "steer" | "queue" - } - | { - type: "synthetic" - data: { text: string; description?: string; metadata?: { [x: string]: unknown } } - delivery: "steer" | "queue" - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.succeeded" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; error: { type: string; message: string } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.interrupted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "user" | "shutdown" | "superseded" } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.instructions.updated" - durable: { aggregateID: string; seq: number; version: 2 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; delta: { [x: string]: string | "removed" } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.synthetic" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: unknown } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.skill.activated" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; id: string; name: string; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.shell.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - shell: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number - metadata: { [x: string]: unknown } - time: { started: number; completed?: number } - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.shell.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - shell: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number - metadata: { [x: string]: unknown } - time: { started: number; completed?: number } - } - output: { output: string; cursor: number; size: number; truncated: boolean } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.step.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - agent: string - model: { id: string; providerID: string; variant?: string } - snapshot?: string - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.step.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - snapshot?: string - files?: Array - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.step.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - error: { type: string; message: string } - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.text.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.text.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.reasoning.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: { [x: string]: unknown } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.reasoning.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - ordinal: number - text: string - state?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.input.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; callID: string; name: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.input.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; callID: string; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.called" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - input: { [x: string]: unknown } - executed: boolean - state?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.progress" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { [x: string]: unknown } - content: Array<{ type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string }> - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.success" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { [x: string]: unknown } - content: Array<{ type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string }> - result?: unknown - executed: boolean - resultState?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - error: { type: string; message: string } - result?: unknown - executed: boolean - resultState?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.retry.scheduled" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - attempt: number - at: number - error: { type: string; message: string } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.admitted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; inputID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - reason: "auto" | "manual" - error: { type: string; message: string } - inputID?: string - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.revert.staged" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - revert: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.revert.cleared" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.revert.committed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; to: string } - } - ) - | { type: "log.synced"; aggregateID: string; seq?: number } +export type SessionLogOutput = SessionLogItem export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } @@ -1706,161 +3164,7 @@ export type SessionMessageInput = { readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] } -export type SessionMessageOutput = { - data: - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "agent-switched" - agent: string - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "model-switched" - model: { id: string; providerID: string; variant?: string } - previous?: { id: string; providerID: string; variant?: string } - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - type: "user" - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - text: string - description?: string - type: "synthetic" - } - | { id: string; metadata?: { [x: string]: JsonValue }; time: { created: number }; type: "system"; text: string } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "skill" - skill: string - name: string - text: string - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number; completed?: number } - type: "shell" - shellID: string - command: string - status: "running" | "exited" | "timeout" | "killed" - exit?: number | "Infinity" | "-Infinity" | "NaN" - output?: { output: string; cursor: number; size: number; truncated: boolean } - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number; completed?: number } - type: "assistant" - agent: string - model: { id: string; providerID: string; variant?: string } - content: Array< - | { type: "text"; text: string } - | { - type: "reasoning" - text: string - state?: { [x: string]: JsonValue } - time?: { created: number; completed?: number } - } - | { - type: "tool" - id: string - name: string - executed?: boolean - providerState?: { [x: string]: JsonValue } - providerResultState?: { [x: string]: JsonValue } - state: - | { status: "streaming"; input: string } - | { - status: "running" - input: { [x: string]: JsonValue } - structured: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - } - | { - status: "completed" - input: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - structured: { [x: string]: JsonValue } - result?: JsonValue - } - | { - status: "error" - input: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - structured: { [x: string]: JsonValue } - error: { type: string; message: string } - result?: JsonValue - } - time: { created: number; ran?: number; completed?: number } - } - > - snapshot?: { start?: string; end?: string; files?: Array } - finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - error?: { type: string; message: string } - retry?: { attempt: number; at: number; error: { type: string; message: string } } - } - | ( - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "running" - reason: "auto" | "manual" - summary: string - recent: string - } - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "completed" - reason: "auto" | "manual" - summary: string - recent: string - } - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "failed" - reason: "auto" | "manual" - error: { type: string; message: string } - } - ) -}["data"] +export type SessionMessageOutput = { data: SessionMessageInfo }["data"] export type MessageListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -1881,163 +3185,7 @@ export type MessageListInput = { }["cursor"] } -export type MessageListOutput = { - data: Array< - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "agent-switched" - agent: string - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "model-switched" - model: { id: string; providerID: string; variant?: string } - previous?: { id: string; providerID: string; variant?: string } - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - type: "user" - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - text: string - description?: string - type: "synthetic" - } - | { id: string; metadata?: { [x: string]: JsonValue }; time: { created: number }; type: "system"; text: string } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - type: "skill" - skill: string - name: string - text: string - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number; completed?: number } - type: "shell" - shellID: string - command: string - status: "running" | "exited" | "timeout" | "killed" - exit?: number | "Infinity" | "-Infinity" | "NaN" - output?: { output: string; cursor: number; size: number; truncated: boolean } - } - | { - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number; completed?: number } - type: "assistant" - agent: string - model: { id: string; providerID: string; variant?: string } - content: Array< - | { type: "text"; text: string } - | { - type: "reasoning" - text: string - state?: { [x: string]: JsonValue } - time?: { created: number; completed?: number } - } - | { - type: "tool" - id: string - name: string - executed?: boolean - providerState?: { [x: string]: JsonValue } - providerResultState?: { [x: string]: JsonValue } - state: - | { status: "streaming"; input: string } - | { - status: "running" - input: { [x: string]: JsonValue } - structured: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - } - | { - status: "completed" - input: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - structured: { [x: string]: JsonValue } - result?: JsonValue - } - | { - status: "error" - input: { [x: string]: JsonValue } - content: Array< - { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } - > - structured: { [x: string]: JsonValue } - error: { type: string; message: string } - result?: JsonValue - } - time: { created: number; ran?: number; completed?: number } - } - > - snapshot?: { start?: string; end?: string; files?: Array } - finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - error?: { type: string; message: string } - retry?: { attempt: number; at: number; error: { type: string; message: string } } - } - | ( - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "running" - reason: "auto" | "manual" - summary: string - recent: string - } - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "completed" - reason: "auto" | "manual" - summary: string - recent: string - } - | { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "failed" - reason: "auto" | "manual" - error: { type: string; message: string } - } - ) - > - cursor: { previous?: string | null; next?: string | null } -} +export type MessageListOutput = SessionMessagesResponse export type ModelListInput = { readonly location?: { @@ -2047,34 +3195,7 @@ export type ModelListInput = { export type ModelListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - modelID: string - providerID: string - family?: string - name: string - package?: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - capabilities: { tools: boolean; input: Array; output: Array } - variants: Array<{ - id: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - }> - time: { released: number } - cost: Array<{ - tier?: { type: "context"; size: number } - input: number - output: number - cache: { read: number; write: number } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { context: number; input?: number; output: number } - }> + data: Array } export type ModelDefaultInput = { @@ -2085,34 +3206,7 @@ export type ModelDefaultInput = { export type ModelDefaultOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - modelID: string - providerID: string - family?: string - name: string - package?: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - capabilities: { tools: boolean; input: Array; output: Array } - variants: Array<{ - id: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - }> - time: { released: number } - cost: Array<{ - tier?: { type: "context"; size: number } - input: number - output: number - cache: { read: number; write: number } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { context: number; input?: number; output: number } - } | null + data: ModelInfo | null } export type GenerateTextInput = { @@ -2129,7 +3223,7 @@ export type GenerateTextInput = { }["model"] } -export type GenerateTextOutput = { data: { text: string } }["data"] +export type GenerateTextOutput = GenerateTextResponse["data"] export type ProviderListInput = { readonly location?: { @@ -2139,16 +3233,7 @@ export type ProviderListInput = { export type ProviderListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - integrationID?: string - name: string - disabled?: boolean - package: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - }> + data: Array } export type ProviderGetInput = { @@ -2160,16 +3245,7 @@ export type ProviderGetInput = { export type ProviderGetOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - integrationID?: string - name: string - disabled?: boolean - package: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - } + data: ProviderV2Info } export type IntegrationListInput = { @@ -2180,36 +3256,7 @@ export type IntegrationListInput = { export type IntegrationListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - name: string - methods: Array< - | { - id: string - type: "oauth" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { key: string; op: "eq" | "neq"; value: string } - } - | { - type: "select" - key: string - message: string - options: Array<{ label: string; value: string; hint?: string }> - when?: { key: string; op: "eq" | "neq"; value: string } - } - > - } - | { type: "key"; label?: string } - | { type: "env"; names: Array } - > - connections: Array<{ type: "credential"; id: string; label: string } | { type: "env"; name: string }> - }> + data: Array } export type IntegrationGetInput = { @@ -2221,36 +3268,7 @@ export type IntegrationGetInput = { export type IntegrationGetOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - name: string - methods: Array< - | { - id: string - type: "oauth" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { key: string; op: "eq" | "neq"; value: string } - } - | { - type: "select" - key: string - message: string - options: Array<{ label: string; value: string; hint?: string }> - when?: { key: string; op: "eq" | "neq"; value: string } - } - > - } - | { type: "key"; label?: string } - | { type: "env"; names: Array } - > - connections: Array<{ type: "credential"; id: string; label: string } | { type: "env"; name: string }> - } | null + data: IntegrationInfo | null } export type IntegrationConnectKeyInput = { @@ -2306,24 +3324,7 @@ export type IntegrationAttemptStatusInput = { export type IntegrationAttemptStatusOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: - | { - status: "pending" - time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } - } - | { - status: "complete" - time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } - } - | { - status: "failed" - message: string - time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } - } - | { - status: "expired" - time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } - } + data: IntegrationAttemptStatus } export type IntegrationAttemptCompleteInput = { @@ -2345,39 +3346,26 @@ export type IntegrationAttemptCancelInput = { export type IntegrationAttemptCancelOutput = void -export type ServerMcpListInput = { +export type McpListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ServerMcpListOutput = { +export type McpListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - name: string - status: - | { status: "connected" } - | { status: "pending" } - | { status: "disabled" } - | { status: "failed"; error: string } - | { status: "needs_auth" } - | { status: "needs_client_registration"; error: string } - integrationID?: string - }> + data: Array } -export type ServerMcpResourceCatalogInput = { +export type McpResourceCatalogInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ServerMcpResourceCatalogOutput = { +export type McpResourceCatalogOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - resources: Array<{ server: string; name: string; uri: string; description?: string; mimeType?: string }> - templates: Array<{ server: string; name: string; uriTemplate: string; description?: string; mimeType?: string }> - } + data: McpResourceCatalog } export type CredentialUpdateInput = { @@ -2399,16 +3387,7 @@ export type CredentialRemoveInput = { export type CredentialRemoveOutput = void -export type ProjectListOutput = Array<{ - id: string - worktree: string - vcs?: "git" | "hg" - name?: string - icon?: { url?: string; override?: string; color?: string } - commands?: { start?: string } - time: { created: number; updated: number; initialized?: number } - sandboxes: Array -}> +export type ProjectListOutput = Array export type ProjectCurrentInput = { readonly location?: { @@ -2416,7 +3395,7 @@ export type ProjectCurrentInput = { }["location"] } -export type ProjectCurrentOutput = { id: string; directory: string } +export type ProjectCurrentOutput = ProjectCurrent export type ProjectDirectoriesInput = { readonly projectID: { readonly projectID: string }["projectID"] @@ -2425,7 +3404,7 @@ export type ProjectDirectoriesInput = { }["location"] } -export type ProjectDirectoriesOutput = Array<{ directory: string; strategy?: string }> +export type ProjectDirectoriesOutput = ProjectDirectories export type FormRequestListInput = { readonly location?: { @@ -2435,360 +3414,12 @@ export type FormRequestListInput = { export type FormRequestListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - fields: [ - ( - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - ), - ...Array< - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - >, - ] - }> + data: Array } export type FormListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type FormListOutput = { - data: Array<{ - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - fields: [ - ( - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - ), - ...Array< - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - >, - ] - }> -}["data"] +export type FormListOutput = { data: Array }["data"] export type FormCreateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -3598,376 +4229,21 @@ export type FormCreateInput = { }["fields"] } -export type FormCreateOutput = { - data: { - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - fields: [ - ( - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - ), - ...Array< - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - >, - ] - } -}["data"] +export type FormCreateOutput = { data: FormInfo }["data"] export type FormGetInput = { readonly sessionID: { readonly sessionID: string; readonly formID: string }["sessionID"] readonly formID: { readonly sessionID: string; readonly formID: string }["formID"] } -export type FormGetOutput = { - data: { - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - fields: [ - ( - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - ), - ...Array< - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - >, - ] - } -}["data"] +export type FormGetOutput = { data: FormInfo }["data"] export type FormStateInput = { readonly sessionID: { readonly sessionID: string; readonly formID: string }["sessionID"] readonly formID: { readonly sessionID: string; readonly formID: string }["formID"] } -export type FormStateOutput = { - data: - | { status: "pending" } - | { status: "answered"; answer: { [x: string]: string | number | boolean | Array } } - | { status: "cancelled" } -}["data"] +export type FormStateOutput = { data: FormState }["data"] export type FormReplyInput = { readonly sessionID: { readonly sessionID: string; readonly formID: string }["sessionID"] @@ -3994,22 +4270,12 @@ export type PermissionRequestListInput = { export type PermissionRequestListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { [x: string]: JsonValue } - source?: { type: "tool"; messageID: string; callID: string } - }> + data: Array } export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } -export type PermissionSavedListOutput = { - data: Array<{ id: string; projectID: string; action: string; resource: string }> -}["data"] +export type PermissionSavedListOutput = { data: Array }["data"] export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] } @@ -4082,38 +4348,18 @@ export type PermissionCreateInput = { }["agent"] } -export type PermissionCreateOutput = { data: { id: string; effect: "allow" | "deny" | "ask" } }["data"] +export type PermissionCreateOutput = { data: { id: string; effect: PermissionV2Effect } }["data"] export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type PermissionListOutput = { - data: Array<{ - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { [x: string]: JsonValue } - source?: { type: "tool"; messageID: string; callID: string } - }> -}["data"] +export type PermissionListOutput = { data: Array }["data"] export type PermissionGetInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] } -export type PermissionGetOutput = { - data: { - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { [x: string]: JsonValue } - source?: { type: "tool"; messageID: string; callID: string } - } -}["data"] +export type PermissionGetOutput = { data: PermissionV2Request }["data"] export type PermissionReplyInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] @@ -4146,7 +4392,7 @@ export type FileListInput = { export type FileListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ path: string; type: "file" | "directory" }> + data: Array } export type FileFindInput = { @@ -4178,7 +4424,7 @@ export type FileFindInput = { export type FileFindOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ path: string; type: "file" | "directory" }> + data: Array } export type CommandListInput = { @@ -4189,14 +4435,7 @@ export type CommandListInput = { export type CommandListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - name: string - template: string - description?: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - subtask?: boolean - }> + data: Array } export type SkillListInput = { @@ -4207,1601 +4446,10 @@ export type SkillListInput = { export type SkillListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - name: string - description?: string - slash?: boolean - autoinvoke?: boolean - location: string - content: string - }> + data: Array } -export type EventSubscribeOutput = - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "models-dev.refreshed" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "integration.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "integration.connection.updated" - location?: { directory: string; workspaceID?: string } - data: { integrationID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "catalog.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "agent.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.created" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - info: { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array<{ - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> - } - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - share?: { url: string } - title: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - version: string - metadata?: { [x: string]: any } - time: { created: number; updated: number; compacting?: number; archived?: number } - permission?: Array<{ permission: string; pattern: string; action: "allow" | "deny" | "ask" }> - revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string } - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.updated" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - info: { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array<{ - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> - } - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - share?: { url: string } - title: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - version: string - metadata?: { [x: string]: any } - time: { created: number; updated: number; compacting?: number; archived?: number } - permission?: Array<{ permission: string; pattern: string; action: "allow" | "deny" | "ask" }> - revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string } - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.deleted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - info: { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array<{ - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> - } - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - share?: { url: string } - title: string - agent?: string - model?: { id: string; providerID: string; variant?: string } - version: string - metadata?: { [x: string]: any } - time: { created: number; updated: number; compacting?: number; archived?: number } - permission?: Array<{ permission: string; pattern: string; action: "allow" | "deny" | "ask" }> - revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string } - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "message.updated" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - info: - | { - id: string - sessionID: string - role: "user" - time: { created: number } - format?: - | ( - | { type: "text" } - | { type: "json_schema"; schema: { [x: string]: any }; retryCount?: number | undefined | undefined } - ) - | undefined - summary?: - | { - title?: string | undefined - body?: string | undefined - diffs: Array<{ - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> - } - | undefined - agent: string - model: { providerID: string; modelID: string; variant?: string | undefined } - system?: string | undefined - tools?: { [x: string]: boolean } | undefined - } - | { - id: string - sessionID: string - role: "assistant" - time: { created: number; completed?: number | undefined } - error?: - | { name: "ProviderAuthError"; data: { providerID: string; message: string } } - | { name: "UnknownError"; data: { message: string; ref?: string | undefined } } - | { name: "MessageOutputLengthError"; data: {} } - | { name: "MessageAbortedError"; data: { message: string } } - | { name: "StructuredOutputError"; data: { message: string; retries: number } } - | { name: "ContextOverflowError"; data: { message: string; responseBody?: string | undefined } } - | { name: "ContentFilterError"; data: { message: string } } - | { - name: "APIError" - data: { - message: string - statusCode?: number | undefined - isRetryable: boolean - responseHeaders?: { [x: string]: string } | undefined - responseBody?: string | undefined - metadata?: { [x: string]: string } | undefined - } - } - | undefined - parentID: string - modelID: string - providerID: string - mode: string - agent: string - path: { cwd: string; root: string } - summary?: boolean | undefined - cost: number - tokens: { - total?: number | undefined - input: number - output: number - reasoning: number - cache: { read: number; write: number } - } - structured?: any | undefined - variant?: string | undefined - finish?: string | undefined - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "message.removed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; messageID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "message.part.updated" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - part: - | { - id: string - sessionID: string - messageID: string - type: "text" - text: string - synthetic?: boolean | undefined - ignored?: boolean | undefined - time?: { start: number; end?: number | undefined } | undefined - metadata?: { [x: string]: any } | undefined - } - | { - id: string - sessionID: string - messageID: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { providerID: string; modelID: string } | undefined - command?: string | undefined - } - | { - id: string - sessionID: string - messageID: string - type: "reasoning" - text: string - metadata?: { [x: string]: any } | undefined - time: { start: number; end?: number | undefined } - } - | { - id: string - sessionID: string - messageID: string - type: "file" - mime: string - filename?: string | undefined - url: string - source?: - | ( - | { text: { value: string; start: number; end: number }; type: "file"; path: string } - | { - text: { value: string; start: number; end: number } - type: "symbol" - path: string - range: { start: { line: number; character: number }; end: { line: number; character: number } } - name: string - kind: number - } - | { - text: { value: string; start: number; end: number } - type: "resource" - clientName: string - uri: string - } - ) - | undefined - } - | { - id: string - sessionID: string - messageID: string - type: "tool" - callID: string - tool: string - state: - | { status: "pending"; input: { [x: string]: any }; raw: string } - | { - status: "running" - input: { [x: string]: any } - title?: string | undefined - metadata?: { [x: string]: any } | undefined - time: { start: number } - } - | { - status: "completed" - input: { [x: string]: any } - output: string - title: string - metadata: { [x: string]: any } - time: { start: number; end: number; compacted?: number | undefined } - attachments?: - | Array<{ - id: string - sessionID: string - messageID: string - type: "file" - mime: string - filename?: string | undefined - url: string - source?: - | ( - | { text: { value: string; start: number; end: number }; type: "file"; path: string } - | { - text: { value: string; start: number; end: number } - type: "symbol" - path: string - range: { - start: { line: number; character: number } - end: { line: number; character: number } - } - name: string - kind: number - } - | { - text: { value: string; start: number; end: number } - type: "resource" - clientName: string - uri: string - } - ) - | undefined - }> - | undefined - } - | { - status: "error" - input: { [x: string]: any } - error: string - metadata?: { [x: string]: any } | undefined - time: { start: number; end: number } - } - metadata?: { [x: string]: any } | undefined - } - | { id: string; sessionID: string; messageID: string; type: "step-start"; snapshot?: string | undefined } - | { - id: string - sessionID: string - messageID: string - type: "step-finish" - reason: string - snapshot?: string | undefined - cost: number - tokens: { - total?: number | undefined - input: number - output: number - reasoning: number - cache: { read: number; write: number } - } - } - | { id: string; sessionID: string; messageID: string; type: "snapshot"; snapshot: string } - | { id: string; sessionID: string; messageID: string; type: "patch"; hash: string; files: Array } - | { - id: string - sessionID: string - messageID: string - type: "agent" - name: string - source?: { value: string; start: number; end: number } | undefined - } - | { - id: string - sessionID: string - messageID: string - type: "retry" - attempt: number - error: { - name: "APIError" - data: { - message: string - statusCode?: number | undefined - isRetryable: boolean - responseHeaders?: { [x: string]: string } | undefined - responseBody?: string | undefined - metadata?: { [x: string]: string } | undefined - } - } - time: { created: number } - } - | { - id: string - sessionID: string - messageID: string - type: "compaction" - auto: boolean - overflow?: boolean | undefined - tail_start_id?: string | undefined - } - time: number - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "message.part.removed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; messageID: string; partID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.agent.selected" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; agent: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.model.selected" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; model: { id: string; providerID: string; variant?: string } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.moved" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; location: { directory: string; workspaceID?: string }; subpath?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.renamed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; title: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.usage.updated" - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.deleted" - durable: { aggregateID: string; seq: number; version: 2 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.forked" - durable: { aggregateID: string; seq: number; version: 2 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; parentID: string; parentSeq: number; from?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.input.promoted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; inputID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.input.admitted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - inputID: string - input: - | { - type: "user" - data: { - text: string - files?: Array<{ - data: string - mime: string - source: { type: "inline" } | { type: "uri"; uri: string } - name?: string - description?: string - mention?: { start: number; end: number; text: string } - }> - agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }> - metadata?: { [x: string]: unknown } - } - delivery: "steer" | "queue" - } - | { - type: "synthetic" - data: { text: string; description?: string; metadata?: { [x: string]: unknown } } - delivery: "steer" | "queue" - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.succeeded" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; error: { type: string; message: string } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.execution.interrupted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "user" | "shutdown" | "superseded" } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.instructions.updated" - durable: { aggregateID: string; seq: number; version: 2 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; delta: { [x: string]: string | "removed" } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.synthetic" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: unknown } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.skill.activated" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; id: string; name: string; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.shell.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - shell: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number - metadata: { [x: string]: unknown } - time: { started: number; completed?: number } - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.shell.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - shell: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number - metadata: { [x: string]: unknown } - time: { started: number; completed?: number } - } - output: { output: string; cursor: number; size: number; truncated: boolean } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.step.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - agent: string - model: { id: string; providerID: string; variant?: string } - snapshot?: string - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.step.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - snapshot?: string - files?: Array - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.step.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - error: { type: string; message: string } - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.text.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.text.delta" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.text.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.reasoning.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: { [x: string]: unknown } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.reasoning.delta" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.reasoning.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - ordinal: number - text: string - state?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.input.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; callID: string; name: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.input.delta" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; callID: string; delta: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.input.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; assistantMessageID: string; callID: string; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.called" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - input: { [x: string]: unknown } - executed: boolean - state?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.progress" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { [x: string]: unknown } - content: Array<{ type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string }> - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.success" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { [x: string]: unknown } - content: Array<{ type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string }> - result?: unknown - executed: boolean - resultState?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.tool.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - callID: string - error: { type: string; message: string } - result?: unknown - executed: boolean - resultState?: { [x: string]: unknown } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.retry.scheduled" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - assistantMessageID: string - attempt: number - at: number - error: { type: string; message: string } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.admitted" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; inputID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.delta" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.compaction.failed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; reason: "auto" | "manual"; error: { type: string; message: string }; inputID?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.revert.staged" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - revert: { - messageID: string - partID?: string - snapshot?: string - files?: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.revert.cleared" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.revert.committed" - durable: { aggregateID: string; seq: number; version: 1 } - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; to: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "filesystem.changed" - location?: { directory: string; workspaceID?: string } - data: { file: string; event: "add" | "change" | "unlink" } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "reference.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "permission.v2.asked" - location?: { directory: string; workspaceID?: string } - data: { - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { [x: string]: unknown } - source?: { type: "tool"; messageID: string; callID: string } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "permission.v2.replied" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "plugin.added" - location?: { directory: string; workspaceID?: string } - data: { id: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "plugin.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "project.directories.updated" - location?: { directory: string; workspaceID?: string } - data: { projectID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "command.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "config.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "skill.updated" - location?: { directory: string; workspaceID?: string } - data: {} - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "pty.created" - location?: { directory: string; workspaceID?: string } - data: { - info: { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "pty.updated" - location?: { directory: string; workspaceID?: string } - data: { - info: { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "pty.exited" - location?: { directory: string; workspaceID?: string } - data: { id: string; exitCode: number } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "pty.deleted" - location?: { directory: string; workspaceID?: string } - data: { id: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "shell.created" - location?: { directory: string; workspaceID?: string } - data: { - info: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number - metadata: { [x: string]: unknown } - time: { started: number; completed?: number } - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "shell.exited" - location?: { directory: string; workspaceID?: string } - data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "shell.deleted" - location?: { directory: string; workspaceID?: string } - data: { id: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "question.v2.asked" - location?: { directory: string; workspaceID?: string } - data: { - id: string - sessionID: string - questions: Array<{ - question: string - header: string - options: Array<{ label: string; description: string }> - multiple?: boolean - custom?: boolean - }> - tool?: { messageID: string; callID: string } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "question.v2.replied" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; requestID: string; answers: Array> } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "question.v2.rejected" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; requestID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "form.created" - location?: { directory: string; workspaceID?: string } - data: { - form: { - id: string - sessionID: string - title: string - metadata?: { [x: string]: unknown } - fields: [ - ( - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "number" - minimum?: number - maximum?: number - default?: number - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "integer" - minimum?: number - maximum?: number - default?: number - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - ), - ...Array< - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "number" - minimum?: number - maximum?: number - default?: number - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "integer" - minimum?: number - maximum?: number - default?: number - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - | { key: string; type: "external"; url: string; title?: string; description?: string } - >, - ] - } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "form.replied" - location?: { directory: string; workspaceID?: string } - data: { id: string; sessionID: string; answer: { [x: string]: string | number | boolean | Array } } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "form.cancelled" - location?: { directory: string; workspaceID?: string } - data: { id: string; sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.status" - location?: { directory: string; workspaceID?: string } - data: { - sessionID: string - status: - | { type: "idle" } - | { - type: "retry" - attempt: number - message: string - action?: { - reason: string - provider: string - title: string - message: string - label: string - link?: string - } - next: number - } - | { type: "busy" } - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.idle" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "tui.prompt.append" - location?: { directory: string; workspaceID?: string } - data: { text: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "tui.command.execute" - location?: { directory: string; workspaceID?: string } - data: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.background" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "tui.toast.show" - location?: { directory: string; workspaceID?: string } - data: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number | undefined - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "tui.session.select" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "installation.updated" - location?: { directory: string; workspaceID?: string } - data: { version: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "installation.update-available" - location?: { directory: string; workspaceID?: string } - data: { version: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "vcs.branch.updated" - location?: { directory: string; workspaceID?: string } - data: { branch?: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "mcp.status.changed" - location?: { directory: string; workspaceID?: string } - data: { server: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "mcp.resources.changed" - location?: { directory: string; workspaceID?: string } - data: { server: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "permission.asked" - location?: { directory: string; workspaceID?: string } - data: { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { [x: string]: unknown } - always: Array - tool?: { messageID: string; callID: string } | undefined - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "permission.replied" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "question.asked" - location?: { directory: string; workspaceID?: string } - data: { - id: string - sessionID: string - questions: Array<{ - question: string - header: string - options: Array<{ label: string; description: string }> - multiple?: boolean | undefined - custom?: boolean | undefined - }> - tool?: { messageID: string; callID: string } | undefined - } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "question.replied" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; requestID: string; answers: Array> } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "question.rejected" - location?: { directory: string; workspaceID?: string } - data: { sessionID: string; requestID: string } - } - | { - id: string - created: number - metadata?: { [x: string]: unknown } - type: "session.error" - location?: { directory: string; workspaceID?: string } - data: { - sessionID?: string | undefined - error?: - | { name: "ProviderAuthError"; data: { providerID: string; message: string } } - | { name: "UnknownError"; data: { message: string; ref?: string | undefined } } - | { name: "MessageOutputLengthError"; data: {} } - | { name: "MessageAbortedError"; data: { message: string } } - | { name: "StructuredOutputError"; data: { message: string; retries: number } } - | { name: "ContextOverflowError"; data: { message: string; responseBody?: string | undefined } } - | { name: "ContentFilterError"; data: { message: string } } - | { - name: "APIError" - data: { - message: string - statusCode?: number | undefined - isRetryable: boolean - responseHeaders?: { [x: string]: string } | undefined - responseBody?: string | undefined - metadata?: { [x: string]: string } | undefined - } - } - | undefined - } - } - | { - id: string - metadata?: { [x: string]: unknown } | undefined - location?: { directory: string; workspaceID?: string } | undefined - type: "server.connected" - data: {} - } +export type EventSubscribeOutput = V2Event export type PtyListInput = { readonly location?: { @@ -5811,16 +4459,7 @@ export type PtyListInput = { export type PtyListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - }> + data: Array } export type PtyCreateInput = { @@ -5866,16 +4505,7 @@ export type PtyCreateInput = { export type PtyCreateOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - } + data: Pty } export type PtyGetInput = { @@ -5887,16 +4517,7 @@ export type PtyGetInput = { export type PtyGetOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - } + data: Pty } export type PtyUpdateInput = { @@ -5913,16 +4534,7 @@ export type PtyUpdateInput = { export type PtyUpdateOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - } + data: Pty } export type PtyRemoveInput = { @@ -5942,18 +4554,7 @@ export type ShellListInput = { export type ShellListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "Infinity" | "-Infinity" | "NaN" - metadata: { [x: string]: JsonValue } - time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" } - }> + data: Array } export type ShellCreateInput = { @@ -5988,18 +4589,7 @@ export type ShellCreateInput = { export type ShellCreateOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "Infinity" | "-Infinity" | "NaN" - metadata: { [x: string]: JsonValue } - time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" } - } + data: ShellInfo1 } export type ShellGetInput = { @@ -6011,18 +4601,7 @@ export type ShellGetInput = { export type ShellGetOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "Infinity" | "-Infinity" | "NaN" - metadata: { [x: string]: JsonValue } - time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" } - } + data: ShellInfo1 } export type ShellTimeoutInput = { @@ -6035,18 +4614,7 @@ export type ShellTimeoutInput = { export type ShellTimeoutOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "Infinity" | "-Infinity" | "NaN" - metadata: { [x: string]: JsonValue } - time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" } - } + data: ShellInfo1 } export type ShellOutputInput = { @@ -6090,36 +4658,12 @@ export type QuestionRequestListInput = { export type QuestionRequestListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - id: string - sessionID: string - questions: Array<{ - question: string - header: string - options: Array<{ label: string; description: string }> - multiple?: boolean - custom?: boolean - }> - tool?: { messageID: string; callID: string } - }> + data: Array } export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type QuestionListOutput = { - data: Array<{ - id: string - sessionID: string - questions: Array<{ - question: string - header: string - options: Array<{ label: string; description: string }> - multiple?: boolean - custom?: boolean - }> - tool?: { messageID: string; callID: string } - }> -}["data"] +export type QuestionListOutput = { data: Array }["data"] export type QuestionReplyInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] @@ -6144,15 +4688,7 @@ export type ReferenceListInput = { export type ReferenceListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - name: string - path: string - description?: string - hidden?: boolean - source: - | { type: "local"; path: string; description?: string; hidden?: boolean } - | { type: "git"; repository: string; branch?: string; description?: string; hidden?: boolean } - }> + data: Array } export type ProjectCopyCreateInput = { @@ -6165,7 +4701,7 @@ export type ProjectCopyCreateInput = { readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"] } -export type ProjectCopyCreateOutput = { directory: string } +export type ProjectCopyCreateOutput = ProjectCopyCopy export type ProjectCopyRemoveInput = { readonly projectID: { readonly projectID: string }["projectID"] @@ -6195,7 +4731,7 @@ export type VcsStatusInput = { export type VcsStatusOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ file: string; additions: number; deletions: number; status: "added" | "deleted" | "modified" }> + data: Array } export type VcsDiffInput = { @@ -6218,16 +4754,10 @@ export type VcsDiffInput = { export type VcsDiffOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array<{ - file: string - patch: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" - }> + data: Array } -export type DebugLocationListOutput = Array<{ directory: string; workspaceID?: string }> +export type DebugLocationListOutput = Array export type DebugLocationEvictInput = { readonly location?: { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index e29522c226..ba822f28e5 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -284,6 +284,43 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => { }) }) +test("event.subscribe accepts a fragmented SSE event below the size limit", async () => { + const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } } + const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response( + new ReadableStream({ + start(controller) { + for (let offset = 0; offset < encoded.length; offset += 64 * 1024) { + controller.enqueue(encoded.slice(offset, offset + 64 * 1024)) + } + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + }) + + await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event }) +}) + +test("event.subscribe rejects an SSE event above the size limit", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response(`data: ${JSON.stringify({ output: "x".repeat(16 * 1024 * 1024) })}`, { + headers: { "content-type": "text/event-stream" }, + }), + }) + + await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + name: "ClientError", + reason: "SseEventTooLarge", + }) +}) + test("session methods use the public HTTP contract", async () => { const requests: Array<{ url: string; init?: RequestInit }> = [] const client = OpenCode.make({ diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 2b764075ae..1b30549ec8 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -1,37 +1,40 @@ # @opencode-ai/codemode -Effect-native confined code execution over explicit, schema-described tools. +This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's +own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter +itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a +bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents +exactly what is supported. -CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority. +[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes +generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application +runs, no sandbox required. -The package is currently private to this workspace. Its API is designed around one-shot and reusable execution: +## How it differs from JavaScript -```ts -// One execution -yield * CodeMode.execute({ tools, code }) +The deliberate differences: -// A reusable runtime -const runtime = CodeMode.make({ tools, limits }) -yield * runtime.execute(code) -``` +- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard + library and the `tools` tree. +- **No dynamic code.** No `eval`, `Function`, or module loading. +- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp, + Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary. +- **Eager, supervised promises.** Tool calls and async functions start immediately when called. Whatever is still + running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must + await every call whose completion matters. Rejections that settle un-awaited become `warnings` on the result instead + of crashing the run. +- **REPL-style results.** An omitted `return` yields the final top-level expression; `undefined` normalizes to `null`. -## Install - -Within this workspace: - -```json -{ - "dependencies": { - "@opencode-ai/codemode": "workspace:*" - } -} -``` - -Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves. +Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an +`UnsupportedSyntax` diagnostic with a source location, and current gaps (for example thenable assimilation, classes, +generators, and full sparse-array parity) are tracked as unchecked items in the +[interpreter support checklist](./interpreter-support.md). ## Quick Start -Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`: +The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect` +and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed +to programs as `tools`: ```ts import { CodeMode, Tool } from "@opencode-ai/codemode" @@ -60,69 +63,53 @@ const result = `) ``` -`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. - -Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well. +`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics +rather than failing the Effect; host interruption remains interruption. ## API ### `Tool.make` -```ts -const tool = Tool.make({ - description, - input, // Effect Schema (validating) or JSON Schema (render-only) - output, // optional; same choice - run, -}) -``` +`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input +is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON +Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise`. +Descriptions and schemas are model-visible contract; keep authorization in `run`. -`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary). +### `CodeMode.execute` and `CodeMode.make` -`output` is optional. Without it the tool's signature advertises `Promise` and the host result is exposed as-is. - -The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls. - -Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`. - -### `CodeMode.execute` - -Use `CodeMode.execute` for a single execution: +`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A +runtime from `make` reuses the tool set and policy: ```ts -const result = - yield * - CodeMode.execute({ - tools: { orders: { lookup: lookupOrder } }, - code: `return await tools.orders.lookup({ id: "order_42" })`, - limits: { maxToolCalls: 10 }, - onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call), - onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call), - }) -``` - -The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations. - -### `CodeMode.make` - -Use `CodeMode.make` when the tool set and execution policy are reused: - -```ts -const runtime = CodeMode.make({ - tools: { orders: { lookup: lookupOrder } }, - limits: { timeoutMs: 30_000 }, -}) +const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } }) runtime.catalog() // structured tool descriptions runtime.instructions() // model-facing syntax and tool guide runtime.execute(source) // CodeMode.Result ``` -`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool. +The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional +`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are +Effect-returning and must not fail. -All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types. +### OpenAPI tools -### Results +`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted +`operationId`: + +```ts +const api = OpenAPI.fromSpec({ spec, auth: { resolve } }) +const runtime = CodeMode.make({ tools: { opencode: api.tools } }) +``` + +It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary +responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never +model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in +`src/openapi/types.ts` for full semantics. + +## Outputs + +Every execution returns a `CodeMode.Result`: ```ts type Result = Success | Failure @@ -145,152 +132,11 @@ interface Failure { } ``` -`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits). +`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections, +timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and +`toolCalls` lists admitted calls in order - retained on failure for auditing. -### Tool-call hooks - -`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately. - -`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail. - -### OpenAPI tools - -`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace. - -```ts -import { CodeMode, OpenAPI } from "@opencode-ai/codemode" -import { Effect } from "effect" -import { FetchHttpClient } from "effect/unstable/http" - -const api = OpenAPI.fromSpec({ - spec: await Bun.file("openapi.json").json(), // parsed document (no YAML) - auth: { - resolve: ({ name, scopes, operation }) => - name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined), - }, -}) - -const runtime = CodeMode.make({ tools: { opencode: api.tools } }) -const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer))) -``` - -`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`. - -Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes. - -## Discovery - -The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). - -The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime: - -```ts -const runtime = CodeMode.make({ - tools, - discovery: { catalogBudget: 6_000 }, -}) -``` - -The budget must be a non-negative safe integer. - -The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial: - -```ts -const matches = await tools.$codemode.search({ - query: "order status", - namespace: "orders", // optional: scope to one top-level namespace - limit: 10, - offset: 0, -}) -``` - -`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit. - -```ts -const request = { query: "order status", namespace: "orders", limit: 10 } -const page = await tools.$codemode.search(request) -const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined -``` - -Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). - -```ts -tools.github.list_issues(input: { - /** Repository owner */ - owner: string, - /** Cursor from the previous response's pageInfo */ - after?: string, - /** - * Results per page - * @default 30 - */ - perPage?: number, -}): Promise -``` - -Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone. - -The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name. - -A host cannot define its own `$codemode` top-level namespace. - -## Supported Programs - -CodeMode executes a deliberately bounded JavaScript subset. See the -[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library -matrix, known semantic gaps, and intentional exclusions. - -At a high level, it supports: - -- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining, - and structured error handling. -- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs. -- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work. -- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries. -- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures. - -It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an -`UnsupportedSyntax` diagnostic with a source location when available. - -CodeMode is an orchestration language, not a general JavaScript runtime. - -## Execution Limits - -The limits are exactly three knobs: - -| Limit | Default | Bounds | -| ---------------- | -------------------: | ---------------------------------------------------- | -| `timeoutMs` | none - no timeout | Wall-clock execution time. | -| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. | -| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. | - -No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. - -Pass only the overrides you need: - -```ts -const runtime = CodeMode.make({ - tools, - limits: { - maxToolCalls: 20, - timeoutMs: 60_000, - }, -}) -``` - -Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset. - -`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number. - -Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`. - -When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded. - -Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract. - -## Diagnostics - -Failures are data: +Failure `error` and success `warnings` share one diagnostic vocabulary: | Kind | Meaning | | ----------------------- | --------------------------------------------------------------------------------------------------------- | @@ -306,58 +152,45 @@ Failures are data: | `ExecutionFailure` | The program threw or another execution error occurred. | | `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. | -Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`: +Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel +for a model-visible refusal; its optional cause never crosses the boundary. -```ts -import { toolError } from "@opencode-ai/codemode" +## Discovery -run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable"))) -``` +The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with +`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected +round-robin so every namespace gets representation, and the instructions state whether the list is complete or +partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial: +synchronous, deterministic field-weighted substring matching that returns directly callable paths with full +signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as +lookup. Search counts as an admitted tool call. -Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary. +## Execution Limits -## Authority Boundary +| Limit | Default | Bounds | +| ---------------- | -------------------: | ---------------------------------------------------- | +| `timeoutMs` | none - no timeout | Wall-clock execution time. | +| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. | +| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. | -CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do. +No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or +interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a +`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated +with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program +already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed +constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries. -The host owns: +## Boundaries and Non-Goals -- Authentication and authorization. -- Tool selection and immutable scope. -- Credentials and network clients. -- Persistence, idempotency, approval, and durable side effects. -- Logging and redaction policy. +The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy. +CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program +can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt +to restrict it. -CodeMode owns: - -- Parsing and interpreting the supported subset without `eval`. -- Schema boundaries around tool calls. -- Plain-data copying and blocked prototype members. -- Resource limits, call accounting, and normalized diagnostics. -- Model-facing tool discovery and instructions. - -A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it. - -## Laws - -The public contract is guided by these equivalences: - -- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`. -- A tool implementation is not invoked unless its input has decoded successfully. -- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully. -- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel. -- Host interruption remains interruption rather than a `CodeMode.Failure`. - -## Non-Goals - -- Generic permission prompts or approval workflows. -- Durable pause/resume, replay, or storage adapters. -- Exactly-once external side effects. -- Application authorization or product policy. -- A filesystem or process sandbox for arbitrary JavaScript. -- Compatibility with the full JavaScript language or npm ecosystem. - -Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools. +Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects, +application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm +ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only +the currently authorized tools. ## Testing @@ -367,5 +200,3 @@ From the package directory: bun test bun run typecheck ``` - -The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 0428ff0251..22092c533e 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -32,7 +32,7 @@ CodeMode is an orchestration language, not a general JavaScript runtime or an ap The generic runtime lives in `packages/codemode` and is host-neutral: 1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`. -2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool. +2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in. 3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter executes it without `eval`. 4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side. @@ -46,13 +46,14 @@ advertised as `Promise`. ### Discovery and model workflow The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected -round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable -and is advertised when the inline catalog is partial. +round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is +always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is +partial. The intended workflow is: -1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path - in the next execution. +1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the + next execution. 2. Call the exact returned path without guessing or normalizing segments. 3. Narrow `Promise` results before reading fields. 4. Start independent calls together and await them with `Promise.all`. @@ -64,10 +65,16 @@ path lookup, namespace browsing, deterministic ranking, and pagination. ### Tool execution Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls, -async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested -functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection -is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result -wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers, +async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the +`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime +of work they started. +Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler. +`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers +continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the +executor first-class resolve/reject callables that may escape and settle the promise later, exactly once. +Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach +order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count +parity beyond that. At normal completion CodeMode interrupts everything still running - race losers, fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy. diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 6887fdaed9..0bfef64ef3 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -22,6 +22,8 @@ ultimate source of truth. - [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`. - [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox. - [x] Tool calls through the host-provided `tools` tree only. +- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is + shadowable by program declarations like other globals. - [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls. - [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language. @@ -92,8 +94,9 @@ ultimate source of truth. - [x] Optional property access and optional calls. - [x] Function/tool calls and spread arguments. - [x] Sequence expressions (the comma operator). -- [x] `await` for sandbox promises; awaiting a plain value is a no-op. -- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams. +- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its + continuation one reaction turn. +- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. - [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`. @@ -102,27 +105,41 @@ ultimate source of truth. - [x] Prefix and postfix `++` and `--`. - [x] Plain, arithmetic, bitwise, and logical assignment operators. - [ ] Unary `void` and `delete`. -- [ ] Arbitrary constructors and `new Promise(...)`. +- [ ] Arbitrary constructors. ## Promises and tools - [x] Tool calls start eagerly and return supervised, run-once sandbox promises. - [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program. - [x] `Promise.resolve` and `Promise.reject`. -- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain - values. +- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing + promises and plain values. - [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings. - [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records. - [x] `Promise.race` settles from the first result without cancelling losers at settlement time. - [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed combinator batches overlap as in normal JavaScript. +- [x] Promise chaining with `.then`, `.catch`, and `.finally`: handlers run deferred in attach order, returned + promises are adopted, handler throws reject the derived promise, `.finally` preserves the original settlement + unless its cleanup fails, and direct self-resolution rejects with a `TypeError`. +- [x] Every `await` (including of plain values and already-settled promises) defers its continuation one reaction + turn, so concurrent async functions interleave at await points as in JavaScript. +- [x] Combinators settle one reaction turn after their deciding member (V8-observable ordering): reactions already + attached to members run first, and an aggregate cannot beat a plain value settling in the same turn into a + `Promise.race`. Exact microtask-count parity beyond this observable ordering is not a documented guarantee. - [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is interrupted when the program returns; rejections that settled un-awaited become `Success.warnings` - diagnostics. + diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted + without a warning. - [x] `try`/`catch` can handle awaited tool and promise failures. -- [ ] `Promise.any`. -- [ ] Promise chaining with `.then`, `.catch`, and `.finally`. -- [ ] Custom promise construction with `new Promise(...)`. +- [x] `Promise.any`: first fulfillment wins; all-rejected rejects with an `AggregateError` whose `errors` array holds + the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`. +- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject + callables that settle the promise exactly once (they may escape the executor and settle later); an executor + throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the + promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection + callbacks but remain opaque references that cannot cross the data boundary. +- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises). - [ ] Async iterables, host streams, and stream consumption. ## Objects and properties @@ -153,15 +170,15 @@ ultimate source of truth. - [ ] The mapper and `thisArg` forms of `Array.from`. - [ ] `Array.prototype.toSpliced`. - [ ] Canonical index handling: a key such as `"01"` must not alias index `1`. -- [ ] Complete sparse-array parity. +- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS. - [ ] Correct `findLast` return behavior when its predicate mutates the examined element. ## Strings - [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`. -- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`. +- [x] Trimming: `trim`, `trimStart`, and `trimEnd`. - [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`. -- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. +- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. - [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`. - [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`. - [x] `localeCompare`; locale and options arguments are currently ignored. @@ -258,6 +275,8 @@ ultimate source of truth. - [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with or without `new`. +- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by + an all-rejected `Promise.any`. - [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. - [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. - [x] Catchable interpreter failures and awaited tool failures. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index f35294a227..c53c7b40ab 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,11 +1,6 @@ import { Effect, Schema } from "effect" -import { executeWithLimits } from "./interpreter/runtime.js" -import { - type HostTools, - type Services, - type ToolDescription, - ToolRuntime, -} from "./tool-runtime.js" +import { executeWithLimits } from "./interpreter/execute.js" +import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Definition } from "./tool.js" /** A tool call admitted during an execution. */ @@ -14,15 +9,15 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr /** Resource budgets enforced independently during each CodeMode program execution. */ export type ExecutionLimits = { /** - * Wall-clock milliseconds before execution is interrupted; result delivery additionally - * waits for tool interruption cleanup. No default: absent means no timeout. + * Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup. + * No default: absent means no timeout. */ readonly timeoutMs?: number /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ readonly maxToolCalls?: number /** - * Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate - * budget of the same size. Fixed truncation notices and host formatting are additional. + * Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget; + * truncation notices and host formatting are additional. */ readonly maxOutputBytes?: number } @@ -99,7 +94,6 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String }) export const Success = Schema.Struct({ ok: Schema.Literal(true), value: Schema.Json, - // Runtime-authored non-fatal diagnostics; program console output stays in `logs`. warnings: Schema.optionalKey(Schema.Array(Diagnostic)), logs: Schema.optionalKey(Schema.Array(Schema.String)), truncated: Schema.optionalKey(Schema.Boolean), @@ -130,11 +124,7 @@ export type Runtime = { readonly execute: (code: string) => Effect.Effect } -const validateLimit = ( - name: keyof ExecutionLimits, - value: Value, - minimum: number, -): Value => { +const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => { if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) } @@ -152,7 +142,6 @@ export const execute = >( options: ExecuteOptions, ): Effect.Effect> => { const tools = (options.tools ?? {}) as HostTools> - ToolRuntime.assertValidTools(tools) return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) } @@ -161,7 +150,6 @@ export const make = = {}>( options: Options = {} as Options, ): Runtime> => { const tools = (options.tools ?? {}) as HostTools> - ToolRuntime.assertValidTools(tools) const limits = resolveExecutionLimits(options.limits) const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts new file mode 100644 index 0000000000..58ddb8febd --- /dev/null +++ b/packages/codemode/src/interpreter/errors.ts @@ -0,0 +1,93 @@ +import type { Diagnostic } from "../codemode.js" +import { ToolError } from "../tool-error.js" +import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" +import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js" +import { containsRuntimeReference } from "./references.js" +import { spreadItems } from "../stdlib/collections.js" +import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js" + +export const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolError) { + return { kind: "ToolFailure", message: error.message } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // Never expose runtime reference internals through thrown values. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if ( + value !== null && + typeof value === "object" && + typeof (value as { message?: unknown }).message === "string" + ) { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: error.message, + } + } + + return { + kind: "ExecutionFailure", + message: String(error), + } +} + +export const caughtErrorValue = (thrown: unknown): unknown => { + if (thrown instanceof ProgramThrow) return thrown.value + if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) + const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" + return createErrorValue(name, normalizeError(thrown).message) +} + +export const constructErrorValue = (name: string, args: Array, node: AstNode): SafeObject => { + if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) + const errors = spreadItems(args[0]) + if (errors === undefined) { + throw new InterpreterRuntimeError( + "new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).", + node, + ).as("TypeError") + } + // Error values must not alias caller-owned arrays. + return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1])) +} diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts new file mode 100644 index 0000000000..1e81a0bfca --- /dev/null +++ b/packages/codemode/src/interpreter/execute.ts @@ -0,0 +1,224 @@ +import { parse } from "acorn" +import { Cause, Effect, Scope } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js" +import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js" +import { normalizeError } from "./errors.js" +import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js" +import { PromiseRuntime } from "./promises.js" +import { Interpreter } from "./runtime.js" + +export const executeWithLimits = >( + options: ExecuteOptions, + limits: ResolvedExecutionLimits, + searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], +): Effect.Effect> => { + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: [], + }) + } + + // Allocate execution state inside suspension so reused Effects never share it. + return Effect.suspend(() => { + const tools = ToolRuntime.make( + (options.tools ?? {}) as HostTools>, + limits.maxToolCalls, + searchIndex, + { + onToolCallStart: options.onToolCallStart, + onToolCallEnd: options.onToolCallEnd, + }, + ) + const logs: Array = [] + const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) + // Set only after copy-out so timeouts cannot report invalid values as completed. + let returned: { value: DataValue; promises: PromiseRuntime> } | undefined + + const base = Effect.acquireUseRelease( + Scope.make("parallel"), + (scope) => + Effect.gen(function* () { + const program = parseProgram(options.code) + const promises = new PromiseRuntime>(scope) + const interpreter = new Interpreter>(tools.invoke, tools.search, tools.keys, promises, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + returned = { value: result, promises } + const warnings = yield* promises.interrupt() + return { + ok: true, + value: result, + ...(warnings.length > 0 ? { warnings } : {}), + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }), + (scope, exit) => Scope.close(scope, exit), + ) + const timeoutMs = limits.timeoutMs + const operation = + timeoutMs === undefined + ? base + : base.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.sync(() => { + if (returned === undefined) { + return { + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + } + // Keep the timeout warning first so truncation preserves it. + return { + ok: true, + value: returned.value, + warnings: [ + { + kind: "TimeoutExceeded", + message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`, + }, + ...returned.promises.diagnostics(), + ], + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }), + }), + ) + + return operation.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.succeed({ + ok: false, + error: normalizeError(Cause.squash(cause)), + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + ), + Effect.map((result) => + limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes), + ), + ) + }) +} + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength + +// Drop a replacement character produced by truncating inside a UTF-8 sequence. +const utf8Truncate = (value: string, maxBytes: number): string => { + const bytes = new TextEncoder().encode(value) + if (bytes.byteLength <= maxBytes) return value + const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) + return text.endsWith("\uFFFD") ? text.slice(0, -1) : text +} + +// Warnings have a separate budget so result data cannot starve diagnostics. +const boundOutput = (result: Result, maxOutputBytes: number): Result => { + let truncated = false + + let value: DataValue = null + let valueBytes = 0 + if (result.ok) { + const serialized = JSON.stringify(result.value) ?? "null" + const bytes = utf8ByteLength(serialized) + if (bytes > maxOutputBytes) { + truncated = true + value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` + valueBytes = maxOutputBytes + } else { + value = result.value + valueBytes = bytes + } + } + + const warnings = result.ok ? (result.warnings ?? []) : [] + const keptWarnings: Array = [] + let warningBytes = 0 + for (const warning of warnings) { + const bytes = utf8ByteLength(JSON.stringify(warning)) + 1 + if (warningBytes + bytes > maxOutputBytes) break + warningBytes += bytes + keptWarnings.push(warning) + } + if (keptWarnings.length < warnings.length) { + truncated = true + keptWarnings.push({ + kind: "Truncated", + message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`, + }) + } + + const logs = result.logs ?? [] + const kept: Array = [] + const logBudget = Math.max(0, maxOutputBytes - valueBytes) + let logBytes = 0 + for (const line of logs) { + const lineBytes = utf8ByteLength(line) + 1 + if (logBytes + lineBytes > logBudget) break + logBytes += lineBytes + kept.push(line) + } + if (kept.length < logs.length) { + truncated = true + kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) + } + + if (!truncated) return result + const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {} + const logsPart = kept.length > 0 ? { logs: kept } : {} + return result.ok + ? { + ok: true, + value, + ...warningsPart, + ...logsPart, + truncated: true, + toolCalls: result.toolCalls, + } + : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } +} diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts new file mode 100644 index 0000000000..365a077837 --- /dev/null +++ b/packages/codemode/src/interpreter/methods.ts @@ -0,0 +1,819 @@ +import { Effect } from "effect" +import { + type AstNode, + CodeModeFunction, + CoercionFunction, + GlobalMethodReference, + IntrinsicReference, + InterpreterRuntimeError, + PromiseCapabilityFunction, + supportedSyntaxMessage, + UriFunction, +} from "./model.js" +import { rejectCircularInsertion } from "./references.js" +import { isBlockedMember, type SafeObject } from "../tool-runtime.js" +import { + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" +import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" +import { invokeJsonMethod } from "../stdlib/json.js" +import { invokeMathMethod } from "../stdlib/math.js" +import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js" +import { invokeObjectMethod } from "../stdlib/object.js" +import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js" +import { invokeStringStatic } from "../stdlib/string.js" +import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js" +import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js" + +export type CallbackRunner = { + readonly invokeFunction: (fn: CodeModeFunction, args: Array) => Effect.Effect + readonly settlePromise: (promise: SandboxPromise) => Effect.Effect +} + +export const invokeIntrinsic = ( + runner: CallbackRunner, + ref: IntrinsicReference, + args: Array, + node: AstNode, +): Effect.Effect => { + if (typeof ref.receiver === "string") { + if ( + (ref.name === "replace" || ref.name === "replaceAll") && + (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction) + ) { + return invokeStringReplacer(runner, ref.receiver, ref.name, args, node) + } + return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) + } + if (Array.isArray(ref.receiver)) { + return invokeArrayMethod(runner, ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxDate) { + return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxRegExp) { + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) + } + if (ref.receiver instanceof SandboxMap) { + return invokeMapMethod(runner, ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxSet) { + return invokeSetMethod(runner, ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxURL) { + return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxURLSearchParams) { + return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) +} + +export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { + if (ref.namespace === "console") + throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) + if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node) + if ( + ref.namespace === "RegExp" || + ref.namespace === "Map" || + ref.namespace === "Set" || + ref.namespace === "URLSearchParams" + ) { + throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) + } + return invokeJsonMethod(ref.name, args, node) +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + + let result: unknown + switch (name) { + case "toLowerCase": + result = value.toLowerCase() + break + case "toUpperCase": + result = value.toUpperCase() + break + case "trim": + result = value.trim() + break + case "trimStart": + result = value.trimStart() + break + case "trimEnd": + result = value.trimEnd() + break + // Locale/options are deliberately unsupported; comparison uses the host default locale. + case "localeCompare": + result = value.localeCompare(str(0)) + break + case "normalize": { + const form = optStr(0) + try { + result = value.normalize(form) + } catch { + throw new InterpreterRuntimeError( + `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, + node, + ).as("RangeError") + } + break + } + case "split": { + if (args.length === 0) { + result = [value] + break + } + if (args[0] instanceof SandboxRegExp) { + result = value.split(args[0].regex, optNum(1)) + break + } + const requestedLimit = optNum(1) + result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) + break + } + case "slice": + result = value.slice(optNum(0), optNum(1)) + break + case "includes": + result = value.includes(str(0), optNum(1)) + break + case "startsWith": + result = value.startsWith(str(0), optNum(1)) + break + case "endsWith": + result = value.endsWith(str(0), optNum(1)) + break + case "indexOf": + result = value.indexOf(str(0), optNum(1)) + break + case "lastIndexOf": + result = value.lastIndexOf(str(0), optNum(1)) + break + case "replace": + case "replaceAll": { + if (args[0] instanceof SandboxRegExp) { + const pattern = args[0].regex + const replacement = str(1) + if (name === "replaceAll" && !pattern.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) + break + } + if (name === "replace") { + result = value.replace(str(0), str(1)) + break + } + result = value.replaceAll(str(0), str(1)) + break + } + case "match": { + const pattern = toHostRegex(args[0], name, node) + const matched = value.match(pattern) + if (matched === null) return null + // Preserve the own `index` and `groups` properties on non-global matches. + if (pattern.global) return boundedData(matched, "String.match result") + return matchToValue(matched) + } + case "matchAll": { + const pattern = toHostRegex(args[0], name, node, "g") + if (!pattern.global) { + throw new InterpreterRuntimeError( + `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, + node, + ) + } + return Array.from(value.matchAll(pattern), matchToValue) + } + case "search": { + result = value.search(toHostRegex(args[0], name, node)) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) + throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + result = value.repeat(count) + break + } + case "padStart": + result = value.padStart(num(0), optStr(1)) + break + case "padEnd": + result = value.padEnd(num(0), optStr(1)) + break + case "charAt": + result = value.charAt(optNum(0) ?? 0) + break + case "at": + result = value.at(optNum(0) ?? 0) + break + case "substring": + result = value.substring(optNum(0) ?? 0, optNum(1)) + break + case "charCodeAt": + result = value.charCodeAt(optNum(0) ?? 0) + break + case "codePointAt": + result = value.codePointAt(optNum(0) ?? 0) + break + case "toString": + result = value + break + case "concat": { + result = value.concat(...args.map((_, index) => str(index))) + break + } + default: + throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `String.${name} result`) +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { + switch (name) { + case "isArray": + return Array.isArray(args[0]) + case "of": + return [...args] + case "from": { + if (args.length > 1) { + throw new InterpreterRuntimeError( + "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values()) + if (args[0] instanceof SandboxURLSearchParams) { + return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) + } + const source = args[0] + if (source instanceof SandboxPromise) { + throw new InterpreterRuntimeError( + "Array.from received an un-awaited Promise; await it before creating the array.", + node, + "InvalidDataValue", + ) + } + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if ( + source !== null && + typeof source === "object" && + (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && + typeof (source as { length?: unknown }).length === "number" + ) { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError( + "Array.from expects an array, string, Map, Set, or array-like value.", + node, + "InvalidDataValue", + ) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) + } +} + +const invokeStringReplacer = ( + runner: CallbackRunner, + value: string, + name: "replace" | "replaceAll", + args: Array, + node: AstNode, +): Effect.Effect => { + const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node) + const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] + const collect = (...callbackArgs: Array): string => { + const match = callbackArgs[0] + const groups = callbackArgs[callbackArgs.length - 1] + const hasGroups = groups !== null && typeof groups === "object" + const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] + if (typeof match !== "string" || typeof offset !== "number") { + throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) + } + if (hasGroups) { + const safeGroups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(groups)) { + if (!isBlockedMember(key)) safeGroups[key] = group + } + callbackArgs[callbackArgs.length - 1] = safeGroups + } + matches.push({ match, offset, args: callbackArgs }) + return match + } + + const pattern = args[0] + if (pattern instanceof SandboxRegExp) { + if (name === "replaceAll" && !pattern.regex.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + if (name === "replace") value.replace(pattern.regex, collect) + else value.replaceAll(pattern.regex, collect) + } else { + if (typeof pattern !== "string") { + throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node) + } + if (name === "replace") value.replace(pattern, collect) + else value.replaceAll(pattern, collect) + } + + return Effect.gen(function* () { + const output: Array = [] + let end = 0 + for (const match of matches) { + const replacement = yield* apply(match.args) + const resolved = + args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise + ? yield* runner.settlePromise(replacement) + : replacement + output.push( + value.slice(end, match.offset), + coerceToString(boundedData(resolved, `String.${name} replacer result`)), + ) + end = match.offset + match.match.length + } + output.push(value.slice(end)) + return boundedData(output.join(""), `String.${name} result`) + }) +} + +export const applyCollectionCallback = ( + runner: CallbackRunner, + callback: unknown, + name: string, + node: AstNode, +): ((args: Array) => Effect.Effect) => { + if ( + !(callback instanceof CodeModeFunction) && + !(callback instanceof CoercionFunction) && + !(callback instanceof UriFunction) && + !(callback instanceof PromiseCapabilityFunction) + ) { + throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) + } + return (callbackArgs) => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : callback instanceof UriFunction + ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) + : callback instanceof PromiseCapabilityFunction + ? Effect.sync(() => callback.settle(callbackArgs[0])) + : runner.invokeFunction(callback, callbackArgs) +} + +const invokeMapMethod = ( + runner: CallbackRunner, + target: SandboxMap, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + switch (name) { + case "get": + return Effect.succeed(target.map.get(args[0])) + case "has": + return Effect.succeed(target.map.has(args[0])) + case "set": + return Effect.sync(() => { + target.map.set(args[0], args[1]) + return target + }) + case "delete": + return Effect.sync(() => target.map.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.map.clear() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.map.keys())) + case "values": + return Effect.sync(() => Array.from(target.map.values())) + case "entries": + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + case "forEach": { + const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node) + return Effect.gen(function* () { + for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeSetMethod = ( + runner: CallbackRunner, + target: SandboxSet, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + switch (name) { + case "has": + return Effect.succeed(target.set.has(args[0])) + case "add": + return Effect.sync(() => { + target.set.add(args[0]) + return target + }) + case "delete": + return Effect.sync(() => target.set.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.set.clear() + return undefined + }) + case "keys": + case "values": + return Effect.sync(() => Array.from(target.set.values())) + case "entries": + return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + case "forEach": { + const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node) + return Effect.gen(function* () { + for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeURLSearchParamsMethod = ( + runner: CallbackRunner, + target: SandboxURLSearchParams, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`) + const requireArgs = (count: number): void => { + if (args.length < count) { + throw new InterpreterRuntimeError( + `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`, + node, + ).as("TypeError") + } + } + switch (name) { + case "append": { + requireArgs(2) + return Effect.sync(() => { + target.params.append(arg(0), arg(1)) + return undefined + }) + } + case "delete": { + requireArgs(1) + return Effect.sync(() => { + if (args[1] !== undefined) target.params.delete(arg(0), arg(1)) + else target.params.delete(arg(0)) + return undefined + }) + } + case "get": + requireArgs(1) + return Effect.sync(() => target.params.get(arg(0))) + case "getAll": + requireArgs(1) + return Effect.sync(() => target.params.getAll(arg(0))) + case "has": + requireArgs(1) + return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)))) + case "set": { + requireArgs(2) + return Effect.sync(() => { + target.params.set(arg(0), arg(1)) + return undefined + }) + } + case "sort": + return Effect.sync(() => { + target.params.sort() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.params.keys())) + case "values": + return Effect.sync(() => Array.from(target.params.values())) + case "entries": + return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) + case "toString": + return Effect.sync(() => target.params.toString()) + case "forEach": { + requireArgs(1) + const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node) + return Effect.gen(function* () { + for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeArrayMethod = ( + runner: CallbackRunner, + target: Array, + name: string, + args: Array, + node: AstNode, +): Effect.Effect => { + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") + throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input") as Array + return Effect.succeed( + input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + ) + } + case "includes": + if (args.length === 0 || args.length > 2) + throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed( + args[1] === undefined + ? target.lastIndexOf(args[0]) + : target.lastIndexOf(args[0], optNumber(args[1], "start index")), + ) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) + case "concat": + return Effect.succeed(target.concat(...args)) + case "flat": + return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) + case "reverse": + return Effect.succeed(target.reverse()) + case "sort": + return Effect.map(sortArray(runner, target, args[0], node), (sorted) => { + target.splice(0, target.length, ...sorted) + return target + }) + case "toSorted": + return sortArray(runner, target, args[0], node) + case "toReversed": + return Effect.succeed([...target].reverse()) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(copied) + } + case "push": { + // Validate all insertions before mutating to avoid partial cyclic updates. + for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node) + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node) + target.unshift(...args) + return Effect.succeed(target.length) + } + case "pop": + return Effect.succeed(target.pop()) + case "shift": + return Effect.succeed(target.shift()) + case "splice": { + if (args.length === 0) return Effect.succeed(target.splice(0, 0)) + const start = optNumber(args[0], "start") ?? 0 + if (args.length === 1) return Effect.succeed(target.splice(start)) + const deleteCount = optNumber(args[1], "delete count") ?? 0 + const inserted = args.slice(2) + for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node) + return Effect.succeed(target.splice(start, deleteCount, ...inserted)) + } + case "fill": { + rejectCircularInsertion(target, args[0], "Array.fill result", node) + return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + } + case "copyWithin": + return Effect.succeed( + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), + ), + ) + case "keys": + return Effect.succeed(Array.from(target.keys())) + case "values": + return Effect.succeed([...target]) + case "entries": + return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) + } + + const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node) + return Effect.gen(function* () { + // Fix iteration length while reading existing elements live. + const length = target.length + switch (name) { + case "map": { + const values: Array = [] + values.length = length + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + values[index] = yield* apply([target[index], index, target]) + } + return values + } + case "flatMap": { + const values: Array = [] + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + const mapped = yield* apply([target[index], index, target]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + } + return values + } + case "filter": { + const values: Array = [] + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + const item = target[index] + if (yield* apply([item, index, target])) values.push(item) + } + return values + } + case "find": + for (let index = 0; index < length; index += 1) { + const item = target[index] + if (yield* apply([item, index, target])) return item + } + return undefined + case "findIndex": + for (let index = 0; index < length; index += 1) { + if (yield* apply([target[index], index, target])) return index + } + return -1 + case "some": + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + if (yield* apply([target[index], index, target])) return true + } + return false + case "every": + for (let index = 0; index < length; index += 1) { + if (!(index in target)) continue + if (!(yield* apply([target[index], index, target]))) return false + } + return true + case "forEach": + for (let index = 0; index < length; index += 1) { + if (index in target) yield* apply([target[index], index, target]) + } + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (length === 0) + throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = target[0] + start = 1 + } + for (let index = start; index < length; index += 1) { + if (!(index in target)) continue + accumulator = yield* apply([accumulator, target[index], index, target]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = length - 1 + } else { + if (length === 0) + throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = target[length - 1] + start = length - 2 + } + for (let index = start; index >= 0; index -= 1) { + if (!(index in target)) continue + accumulator = yield* apply([accumulator, target[index], index, target]) + } + return accumulator + } + case "findLast": + for (let index = length - 1; index >= 0; index -= 1) { + if (yield* apply([target[index], index, target])) return target[index] + } + return undefined + case "findLastIndex": + for (let index = length - 1; index >= 0; index -= 1) { + if (yield* apply([target[index], index, target])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) + }) +} + +const sortArray = ( + runner: CallbackRunner, + target: Array, + comparator: unknown, + node: AstNode, +): Effect.Effect, unknown, R> => { + if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) + } + if (!(comparator instanceof CodeModeFunction)) { + return Effect.sync(() => + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + ) + } + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function* () { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + // Treat a NaN comparator result as equal to preserve stable ordering. + const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) +} diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index e26538550c..b6e6ad64f8 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -1,5 +1,5 @@ import type { SafeObject } from "../tool-runtime.js" -import type { SandboxURL } from "../values.js" +import type { SandboxPromise, SandboxURL } from "../values.js" export type SourcePosition = { line: number @@ -61,12 +61,25 @@ export class ComputedValue { export class PromiseNamespace {} -export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" +export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject" export class PromiseMethodReference { constructor(readonly name: PromiseMethodName) {} } +export type PromiseInstanceMethodName = "then" | "catch" | "finally" + +export class PromiseInstanceMethodReference { + constructor( + readonly promise: SandboxPromise, + readonly name: PromiseInstanceMethodName, + ) {} +} + +export class PromiseCapabilityFunction { + constructor(readonly settle: (value: unknown) => void) {} +} + export type GlobalNamespaceName = | "Object" | "Math" @@ -99,6 +112,8 @@ export class UriFunction { constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {} } +export class SearchFunction {} + export class ProgramThrow { constructor(readonly value: unknown) {} } @@ -122,11 +137,11 @@ export type DiagnosticKind = export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") export const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction." export class InterpreterRuntimeError extends Error { readonly node?: AstNode - errorName: string = "Error" + errorName = "Error" constructor( message: string, diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts new file mode 100644 index 0000000000..95f9b7741f --- /dev/null +++ b/packages/codemode/src/interpreter/promises.ts @@ -0,0 +1,336 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" +import type { Diagnostic } from "../codemode.js" +import type { SafeObject } from "../tool-runtime.js" +import { + type AstNode, + CodeModeFunction, + CoercionFunction, + InterpreterRuntimeError, + ProgramThrow, + PromiseCapabilityFunction, + PromiseInstanceMethodReference, + PromiseMethodReference, + UriFunction, +} from "./model.js" +import { caughtErrorValue, normalizeError } from "./errors.js" +import { applyCollectionCallback, type CallbackRunner } from "./methods.js" +import { typeofValue } from "./references.js" +import { spreadItems } from "../stdlib/collections.js" +import { createAggregateErrorValue } from "../stdlib/value.js" +import { SandboxPromise } from "../values.js" + +// Observation only controls rejection reporting; program completion interrupts all promise work. +export class PromiseRuntime { + private readonly active = new Set() + private readonly ids = new WeakMap() + private readonly observed = new WeakSet() + private readonly failures = new Map() + private nextID = 0 + + constructor(private readonly scope: Scope.Scope) {} + + create(effect: Effect.Effect): Effect.Effect { + return Effect.suspend(() => { + // Allocate before forking so reruns get distinct IDs and diagnostics retain creation order. + const id = this.nextID++ + return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => { + const promise = new SandboxPromise(fiber) + this.active.add(promise) + this.ids.set(promise, id) + fiber.addObserver((exit) => { + this.active.delete(promise) + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) { + this.ids.delete(promise) + return + } + const failure = normalizeError(Cause.squash(exit.cause)) + this.failures.set(id, { + ...failure, + message: `Unhandled rejection from an un-awaited promise: ${failure.message}`, + }) + }) + return promise + }) + }) + } + + // Observation must be recorded when responsibility transfers, before the consumer fiber runs. + markObserved(promise: SandboxPromise): void { + this.observed.add(promise) + const id = this.ids.get(promise) + this.ids.delete(promise) + if (id !== undefined) this.failures.delete(id) + } + + await(promise: SandboxPromise): Effect.Effect> { + return Fiber.await(promise.fiber) + } + + diagnostics(): Array { + return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) + } + + // Re-check because a straggler can create promises before its interruption lands. + interrupt(): Effect.Effect> { + const self = this + return Effect.gen(function* () { + while (self.active.size > 0) { + yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber)) + } + return self.diagnostics() + }) + } +} + +export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") + +export const invokePromiseMethod = ( + runner: CallbackRunner, + promises: PromiseRuntime, + ref: PromiseMethodReference, + args: Array, + node: AstNode, +): Effect.Effect => { + if (ref.name === "resolve") { + const value = args[0] + return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value)) + } + if (ref.name === "reject") { + return promises.create(Effect.fail(new ProgramThrow(args[0]))) + } + + const spread = spreadItems(args[0]) + if (spread === undefined) { + return promises.create( + Effect.fail( + new InterpreterRuntimeError( + `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + node, + ).as("TypeError"), + ), + ) + } + const items = Array.from(spread) + + for (const item of items) { + if (item instanceof SandboxPromise) promises.markObserved(item) + } + + switch (ref.name) { + case "all": { + const observations = items.map((item) => + item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item), + ) + return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" }))) + } + case "allSettled": { + const observations = items.map((item) => + item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), + ) + return promises.create( + settleAfterTurn( + Effect.gen(function* () { + const outcomes: Array = [] + for (const observation of observations) { + const exit = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) + continue + } + if (Cause.hasInterruptsOnly(exit.cause)) { + // Teardown interruption is not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(Cause.squash(exit.cause)), + }), + ) + } + return outcomes + }), + ), + ) + } + case "race": { + if (items.length === 0) { + return promises.create( + Effect.fail( + new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ), + ), + ) + } + const observations = items.map((item) => + item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)), + ) + return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) + } + case "any": { + const flipped = items.map((item) => + item instanceof SandboxPromise + ? Effect.flatMap(promises.await(item), (exit) => { + if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) + if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) + return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) + }) + : Effect.fail(new PromiseAnyFulfilled(item)), + ) + const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe( + Effect.flatMap((reasons) => + Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), + ), + Effect.catch((error) => + error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + ), + ) + return promises.create(settleAfterTurn(body)) + } + } +} + +export const invokePromiseInstanceMethod = ( + runner: CallbackRunner, + promises: PromiseRuntime, + ref: PromiseInstanceMethodReference, + args: Array, + node: AstNode, +): Effect.Effect => { + const method = `Promise.prototype.${ref.name}` + promises.markObserved(ref.promise) + if (ref.name === "finally") { + return chainFinally(runner, promises, ref.promise, reactionHandler(args[0], method, node), method, node) + } + const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined + const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node) + return chainReaction(runner, promises, ref.promise, onFulfilled, onRejected, method, node) +} + +export const constructPromise = ( + runner: CallbackRunner, + promises: PromiseRuntime, + executor: unknown, + node: AstNode, +): Effect.Effect => { + if (!(executor instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError( + "new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).", + node, + ).as("TypeError") + } + return Effect.gen(function* () { + const deferred = Deferred.makeUnsafe() + const box: { own?: SandboxPromise } = {} + const promise = yield* promises.create( + Effect.flatMap(Deferred.await(deferred), (value) => { + if (!(value instanceof SandboxPromise)) return Effect.succeed(value) + if (value === box.own) return Effect.fail(selfResolutionError(node)) + return runner.settlePromise(value) + }), + ) + box.own = promise + const resolve = new PromiseCapabilityFunction((value) => { + Deferred.doneUnsafe(deferred, Exit.succeed(value)) + }) + const reject = new PromiseCapabilityFunction((value) => { + Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))) + }) + const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject])) + if (!Exit.isSuccess(executed)) { + if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) + Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) + } + return promise + }) +} + +// Settle one reaction turn after the deciding member, after its existing reactions. +const settleAfterTurn = (body: Effect.Effect): Effect.Effect => + Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit)) + +class PromiseAnyFulfilled { + constructor(readonly value: unknown) {} +} + +type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction + +const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof PromiseCapabilityFunction + ) { + return value + } + if (typeofValue(value) === "function") { + throw new InterpreterRuntimeError( + `${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`, + node, + ) + } + return undefined +} + +// Teardown bypasses handlers; settled reactions yield once so handlers never run inline. +const reactionExit = ( + promises: PromiseRuntime, + source: SandboxPromise, +): Effect.Effect, unknown, R> => + Effect.gen(function* () { + const exit = yield* promises.await(source) + if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) + yield* Effect.yieldNow + return exit + }) + +const chainReaction = ( + runner: CallbackRunner, + promises: PromiseRuntime, + source: SandboxPromise, + onFulfilled: ReactionHandler | undefined, + onRejected: ReactionHandler | undefined, + method: string, + node: AstNode, +): Effect.Effect => { + const box: { derived?: SandboxPromise } = {} + const body = Effect.gen(function* () { + const exit = yield* reactionExit(promises, source) + const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected + if (handler === undefined) return yield* exit + const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause)) + const result = yield* applyCollectionCallback(runner, handler, method, node)([input]) + if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) + if (result instanceof SandboxPromise) return yield* runner.settlePromise(result) + return result + }) + return Effect.map(promises.create(body), (derived) => { + box.derived = derived + return derived + }) +} + +const chainFinally = ( + runner: CallbackRunner, + promises: PromiseRuntime, + source: SandboxPromise, + cleanup: ReactionHandler | undefined, + method: string, + node: AstNode, +): Effect.Effect => + promises.create( + Effect.gen(function* () { + const exit = yield* reactionExit(promises, source) + if (cleanup !== undefined) { + const result = yield* applyCollectionCallback(runner, cleanup, method, node)([]) + if (result instanceof SandboxPromise) yield* runner.settlePromise(result) + } + return yield* exit + }), + ) diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts new file mode 100644 index 0000000000..afbbdbf9e9 --- /dev/null +++ b/packages/codemode/src/interpreter/references.ts @@ -0,0 +1,99 @@ +import { + type AstNode, + CodeModeFunction, + CoercionFunction, + ErrorConstructorReference, + GlobalMethodReference, + GlobalNamespace, + InterpreterRuntimeError, + IntrinsicReference, + PromiseCapabilityFunction, + PromiseInstanceMethodReference, + PromiseMethodReference, + PromiseNamespace, + SearchFunction, + UriFunction, +} from "./model.js" +import { ToolReference } from "../tool-runtime.js" +import { isSandboxValue, SandboxPromise } from "../values.js" + +export const isRuntimeReference = (value: unknown): boolean => + value instanceof CodeModeFunction || + value instanceof ToolReference || + value instanceof IntrinsicReference || + value instanceof GlobalNamespace || + value instanceof GlobalMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || + value instanceof SandboxPromise || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof SearchFunction || + value instanceof PromiseCapabilityFunction || + value instanceof ErrorConstructorReference || + isSandboxValue(value) + +export const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +// Sandbox values are data here, not opaque interpreter references. +export const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { + if (isSandboxValue(value)) return false + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsOpaqueReference(item, seen)) + : Object.values(value).some((item) => containsOpaqueReference(item, seen)) + seen.delete(value) + return contains +} + +// Reject cycles before mutation so later boundary walks remain safe. +export const rejectCircularInsertion = ( + container: object, + value: unknown, + label: string, + node: AstNode, + seen = new Set(), +): void => { + if (value === container) + throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) +} + +export const typeofValue = (value: unknown): string => { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseCapabilityFunction || + value instanceof ErrorConstructorReference + ) + return "function" + if (value instanceof UriFunction || value instanceof SearchFunction) return "function" + if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" + if (value instanceof GlobalNamespace) { + return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" + } + return typeof value +} diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index ba095e74d5..1770c84e94 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,26 +1,5 @@ -import { parse } from "acorn" -import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect" -import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" -import { - copyIn, - copyOut, - isBlockedMember, - ToolReference, - ToolRuntime, - ToolRuntimeError, - type HostTools, - type SafeObject, - type Services, -} from "../tool-runtime.js" -import { ToolError } from "../tool-error.js" -import type { - DataValue, - Diagnostic, - DiagnosticKind, - ExecuteOptions, - ResolvedExecutionLimits, - Result, -} from "../codemode.js" +import { Cause, Effect, Semaphore } from "effect" +import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { type AstNode, asNode, @@ -31,8 +10,6 @@ import { ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, - type GlobalNamespaceName, - formatLocation, getArray, getBoolean, getNode, @@ -43,50 +20,45 @@ import { isRecord, type MemberReference, OptionalShortCircuit, + PromiseCapabilityFunction, + PromiseInstanceMethodReference, PromiseMethodReference, type PromiseMethodName, PromiseNamespace, ProgramThrow, type ProgramNode, + SearchFunction, type StatementResult, - sourceLocation, supportedSyntaxMessage, unsupportedSyntax, UriFunction, } from "./model.js" +import { caughtErrorValue, constructErrorValue } from "./errors.js" +import { type CallbackRunner, invokeGlobalMethod, invokeIntrinsic } from "./methods.js" +import { + constructPromise, + invokePromiseInstanceMethod, + invokePromiseMethod, + PromiseRuntime, + selfResolutionError, +} from "./promises.js" +import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js" +import { ScopeStack } from "./scope.js" import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" -import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js" -import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" -import { invokeJsonMethod } from "../stdlib/json.js" -import { invokeMathMethod, mathConstants } from "../stdlib/math.js" -import { - invokeNumberMethod, - invokeNumberStatic, - numberConstants, - numberMethods, - numberStatics, -} from "../stdlib/number.js" -import { invokeObjectMethod, objectMethodsPreservingIdentity } from "../stdlib/object.js" +import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js" +import { dateMethods } from "../stdlib/date.js" +import { mathConstants } from "../stdlib/math.js" +import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js" +import { objectMethodsPreservingIdentity } from "../stdlib/object.js" import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" -import { - escapeRegexHint, - invokeRegExpMethod, - matchToValue, - regexpMethods, - regexpProperties, - regexFailureReason, - toHostRegex, -} from "../stdlib/regexp.js" -import { invokeStringStatic, stringMethods, stringStatics } from "../stdlib/string.js" +import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js" +import { stringMethods, stringStatics } from "../stdlib/string.js" import { urlMethods, urlProperties, urlSearchParamsMethods, - urlStatics, urlWritableProperties, invokeUriFunction, - invokeURLMethod, - invokeURLStatic, uriArgument, urlArgument, } from "../stdlib/url.js" @@ -95,7 +67,6 @@ import { coerceToNumber, coerceToString, compoundOperators, - createErrorValue, errorBrandName, errorConstructors, invokeCoercion, @@ -112,189 +83,6 @@ import { SandboxURLSearchParams, } from "../values.js" -const parseProgram = (code: string): ProgramNode => { - const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { - reportDiagnostics: true, - compilerOptions: { - target: ScriptTarget.ESNext, - module: ModuleKind.ESNext, - }, - }) - const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) - - if (diagnostic) { - throw new InterpreterRuntimeError( - `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, - undefined, - "ParseError", - ) - } - - const bodyStart = transpiled.outputText.indexOf("{") + 1 - const bodyEnd = transpiled.outputText.lastIndexOf("}") - const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) - const parsed = parse(executableCode, { - ecmaVersion: "latest", - sourceType: "script", - allowReturnOutsideFunction: true, - allowAwaitOutsideFunction: true, - locations: true, - }) as unknown - - if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { - throw new InterpreterRuntimeError("Failed to parse script as a Program node.") - } - - return parsed as ProgramNode -} - -const publicErrorMessage = (message: string): string => - message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") - -const normalizeError = (error: unknown): Diagnostic => { - if (error instanceof InterpreterRuntimeError) { - return { - kind: error.kind, - message: `${error.message}${formatLocation(error.node)}`, - ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), - ...(error.suggestions ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolRuntimeError) { - return { - kind: error.kind, - message: error.message, - ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolError) { - return { kind: "ToolFailure", message: publicErrorMessage(error.message) } - } - - if (error instanceof ProgramThrow) { - const value = error.value - let message: string - if (containsRuntimeReference(value)) { - // A thrown tool/function reference must not leak its internal structure. - message = "a non-data value" - } else if (typeof value === "string") { - message = value - } else if ( - value !== null && - typeof value === "object" && - typeof (value as { message?: unknown }).message === "string" - ) { - message = (value as { message: string }).message - } else { - try { - message = JSON.stringify(copyOut(value)) ?? String(value) - } catch { - message = String(value) - } - } - return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } - } - - if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { - return { - kind: "ExecutionFailure", - message: "Execution exceeded the maximum nesting depth.", - } - } - - if (error instanceof Error) { - return { - kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", - message: publicErrorMessage(error.message), - } - } - - // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through - // path redaction so filesystem paths can never leak through the catch-all branch. - return { - kind: "ExecutionFailure", - message: publicErrorMessage(String(error)), - } -} - -// Shared by catch bindings and Promise.allSettled rejection reasons. -const caughtErrorValue = (thrown: unknown): unknown => { - if (thrown instanceof ProgramThrow) return thrown.value - if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) - const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" - return createErrorValue(name, normalizeError(thrown).message) -} - -const isRuntimeReference = (value: unknown): boolean => - value instanceof CodeModeFunction || - value instanceof ToolReference || - value instanceof IntrinsicReference || - value instanceof GlobalNamespace || - value instanceof GlobalMethodReference || - value instanceof PromiseNamespace || - value instanceof PromiseMethodReference || - value instanceof SandboxPromise || - value instanceof CoercionFunction || - value instanceof UriFunction || - value instanceof ErrorConstructorReference || - isSandboxValue(value) - -const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsRuntimeReference(item, seen)) - : Object.values(value).some((item) => containsRuntimeReference(item, seen)) - seen.delete(value) - return contains -} - -// Like containsRuntimeReference, but sandbox standard-library values count as data: -// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive -// coercion) rather than rejecting them as opaque interpreter machinery. -const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { - if (isSandboxValue(value)) return false - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsOpaqueReference(item, seen)) - : Object.values(value).some((item) => containsOpaqueReference(item, seen)) - seen.delete(value) - return contains -} - -// `typeof` never throws in JS; map every interpreter value to its JS-visible category. -// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly -// like a real JS promise. -const typeofValue = (value: unknown): string => { - if ( - value instanceof CodeModeFunction || - value instanceof CoercionFunction || - value instanceof IntrinsicReference || - value instanceof GlobalMethodReference || - value instanceof PromiseMethodReference || - value instanceof PromiseNamespace || - value instanceof ErrorConstructorReference - ) - return "function" - if (value instanceof UriFunction) return "function" - if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" - if (value instanceof GlobalNamespace) { - return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" - } - return typeof value -} - -// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any -// left-hand value (opaque references included) without coercing it. Error checks use the -// error brand: `instanceof Error` accepts every branded error; a specific error type matches -// its own brand only (as in JS, where TypeError instances are also Error instances). const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { if (rhs instanceof ErrorConstructorReference) { const brand = errorBrandName(lhs) @@ -321,8 +109,6 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => } } if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise - // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so - // `x instanceof Number` is always false - exactly what it is for primitives in JS. if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { return false } @@ -332,259 +118,6 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => ) } -const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { - const str = (index: number): string => { - const arg = args[index] - if (typeof arg !== "string") - throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) - return arg - } - const num = (index: number): number => { - const arg = args[index] - if (typeof arg !== "number") - throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) - return arg - } - const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) - const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) - - let result: unknown - switch (name) { - case "toLowerCase": - result = value.toLowerCase() - break - case "toUpperCase": - result = value.toUpperCase() - break - case "trim": - result = value.trim() - break - // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. - case "trimStart": - case "trimLeft": - result = value.trimStart() - break - case "trimEnd": - case "trimRight": - result = value.trimEnd() - break - // Locale/options arguments are ignored: comparison runs with the host default locale, and - // the common use is a sort comparator where any consistent order works. - case "localeCompare": - result = value.localeCompare(str(0)) - break - case "normalize": { - const form = optStr(0) - try { - result = value.normalize(form) - } catch { - throw new InterpreterRuntimeError( - `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, - node, - ).as("RangeError") - } - break - } - case "split": { - if (args.length === 0) { - result = [value] - break - } - if (args[0] instanceof SandboxRegExp) { - result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) - break - } - const requestedLimit = optNum(1) - result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) - break - } - case "slice": - result = value.slice(optNum(0), optNum(1)) - break - case "includes": - result = value.includes(str(0), optNum(1)) - break - case "startsWith": - result = value.startsWith(str(0), optNum(1)) - break - case "endsWith": - result = value.endsWith(str(0), optNum(1)) - break - case "indexOf": - result = value.indexOf(str(0), optNum(1)) - break - case "lastIndexOf": - result = value.lastIndexOf(str(0), optNum(1)) - break - case "replace": - case "replaceAll": { - if (args[0] instanceof SandboxRegExp) { - const pattern = (args[0] as SandboxRegExp).regex - const replacement = str(1) - if (name === "replaceAll" && !pattern.global) { - throw new InterpreterRuntimeError( - `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, - node, - ) - } - result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) - break - } - if (name === "replace") { - result = value.replace(str(0), str(1)) - break - } - result = value.replaceAll(str(0), str(1)) - break - } - case "match": { - const pattern = toHostRegex(args[0], name, node) - const matched = value.match(pattern) - if (matched === null) return null - // A global match is a plain array of matched strings; a non-global match carries - // index/groups own properties, so bypass the copying data checkpoint to keep them. - if (pattern.global) return boundedData(matched, "String.match result") - return matchToValue(matched) - } - case "matchAll": { - const pattern = toHostRegex(args[0], name, node, "g") - if (!pattern.global) { - throw new InterpreterRuntimeError( - `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, - node, - ) - } - // Materialized as an array (not an iterator); each entry is a match array with - // index/groups own properties. Match count is bounded by the subject length. - return Array.from(value.matchAll(pattern), matchToValue) - } - case "search": { - result = value.search(toHostRegex(args[0], name, node)) - break - } - case "repeat": { - const count = num(0) - if (!Number.isFinite(count) || count < 0) - throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) - result = value.repeat(count) - break - } - case "padStart": - result = value.padStart(num(0), optStr(1)) - break - case "padEnd": - result = value.padEnd(num(0), optStr(1)) - break - case "charAt": - result = value.charAt(optNum(0) ?? 0) - break - case "at": - result = value.at(optNum(0) ?? 0) - break - case "substring": - result = value.substring(optNum(0) ?? 0, optNum(1)) - break - case "substr": - result = value.substr(optNum(0) ?? 0, optNum(1)) - break - // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value - // (normalized to null only at the data boundary - see copyOut), so return it as-is. - case "charCodeAt": - result = value.charCodeAt(optNum(0) ?? 0) - break - case "codePointAt": - result = value.codePointAt(optNum(0) ?? 0) - break - case "toString": - result = value - break - case "concat": { - result = value.concat(...args.map((_, index) => str(index))) - break - } - default: - throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) - } - return boundedData(result, `String.${name} result`) -} - -const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { - switch (name) { - case "isArray": - return Array.isArray(args[0]) - case "of": - return [...args] - case "from": { - if (args.length > 1) { - throw new InterpreterRuntimeError( - "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - // Map/Set materialize directly (the data checkpoint would serialize them to {}). - if (args[0] instanceof SandboxMap) - return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) - if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) - if (args[0] instanceof SandboxURLSearchParams) { - return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) - } - const source = args[0] - if (source instanceof SandboxPromise) { - throw new InterpreterRuntimeError( - "Array.from received an un-awaited Promise; await it before creating the array.", - node, - "InvalidDataValue", - ) - } - if (typeof source === "string") return Array.from(source) - if (Array.isArray(source)) return [...source] - if ( - source !== null && - typeof source === "object" && - (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && - typeof (source as { length?: unknown }).length === "number" - ) { - return Array.from(source as ArrayLike) - } - throw new InterpreterRuntimeError( - "Array.from expects an array, string, Map, Set, or array-like value.", - node, - "InvalidDataValue", - ) - } - default: - throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) - } -} - -const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { - if (ref.namespace === "console") - throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) - if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) - if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) - if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) - if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) - if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) - if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) - if (ref.namespace === "Date") { - if (!dateStatics.has(ref.name)) - throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) - return invokeDateStatic(ref.name, args, node) - } - if ( - ref.namespace === "RegExp" || - ref.namespace === "Map" || - ref.namespace === "Set" || - ref.namespace === "URLSearchParams" - ) { - throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) - } - return invokeJsonMethod(ref.name, args, node) -} - -// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { switch (pattern.type) { case "Identifier": @@ -611,106 +144,37 @@ const collectPatternNames = (pattern: AstNode, out: Array = []): Array { - private readonly active = new Set() - private readonly ids = new WeakMap() - private readonly observed = new WeakSet() - private readonly failures = new Map() - private nextID = 0 - - constructor(private readonly scope: Scope.Scope) {} - - create(effect: Effect.Effect): Effect.Effect { - return Effect.suspend(() => { - // Allocated at execution time (not construction) so re-run effects cannot share an id, - // and before the fork so diagnostics order by creation: a forked body that immediately - // creates promises of its own must sequence after its creator. - const id = this.nextID++ - return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => { - const promise = new SandboxPromise(fiber) - this.active.add(promise) - this.ids.set(promise, id) - fiber.addObserver((exit) => { - this.active.delete(promise) - if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) { - this.ids.delete(promise) - return - } - const failure = normalizeError(Cause.squash(exit.cause)) - this.failures.set(id, { - ...failure, - message: `Unhandled rejection from an un-awaited promise: ${failure.message}`, - }) - }) - return promise - }) - }) - } - - // Synchronous on purpose: JS makes a promise "handled" the moment a construct takes - // responsibility for it (await, or membership in a combinator call), not when the - // consuming fiber later runs. Call sites must invoke this at that moment. - markObserved(promise: SandboxPromise): void { - this.observed.add(promise) - const id = this.ids.get(promise) - this.ids.delete(promise) - if (id !== undefined) this.failures.delete(id) - } - - // Pure settlement subscription: never re-runs work and never affects rejection reporting. - await(promise: SandboxPromise): Effect.Effect> { - return Fiber.await(promise.fiber) - } - - // Unobserved rejections that already settled, in creation order. - diagnostics(): Array { - return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) - } - - // Normal-completion lifecycle: interrupts everything still running and reports the - // rejections that already settled un-awaited. interruptAll signals every fiber - // synchronously before awaiting termination, so no straggler can spawn new work between - // interrupts; the loop re-checks as a backstop because a straggler can create promises - // before its interrupt lands. - interrupt(): Effect.Effect> { - const self = this - return Effect.gen(function* () { - while (self.active.size > 0) { - yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber)) - } - return self.diagnostics() - }) - } -} - -class Interpreter { - private scopes: Array> +export class Interpreter { + private scopes: ScopeStack private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect - // Enumerable namespace/tool names at a node of the host tool tree, threaded from - // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. + private readonly invokeSearch: (args: Array) => Effect.Effect private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array - // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore private readonly promises: PromiseRuntime + private readonly runner: CallbackRunner = { + invokeFunction: (fn, args) => this.invokeFunction(fn, args), + settlePromise: (promise) => this.settlePromise(promise), + } constructor( invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + invokeSearch: (args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, promises: PromiseRuntime, logs: Array = [], callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY), ) { const globalScope = new Map() - this.scopes = [globalScope] + this.scopes = new ScopeStack([globalScope]) this.invokeTool = invokeTool + this.invokeSearch = invokeSearch this.toolKeys = toolKeys this.logs = logs this.callPermits = callPermits this.promises = promises globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("search", { mutable: false, value: new SearchFunction() }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("undefined", { mutable: false, value: undefined }) globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) @@ -733,23 +197,17 @@ class Interpreter { globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") }) globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") }) globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") }) - // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` - // (with or without `new`) constructs a branded { name, message } error object. for (const name of errorConstructors) { globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) } - // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data - // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. globalScope.set("NaN", { mutable: false, value: NaN }) globalScope.set("Infinity", { mutable: false, value: Infinity }) } run(program: ProgramNode): Effect.Effect { const self = this - // Run the program body in its own module scope on top of the builtin global scope, so - // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like - // JS module scope, instead of colliding with the seeded globals. - this.pushScope() + // Keep top-level declarations separate so they can shadow builtins. + this.scopes.push() return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined @@ -768,21 +226,15 @@ class Interpreter { if (result.kind === "break" || result.kind === "continue") { throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) } - } - // The program body runs inside an implicit async function, so a returned promise - // resolves before crossing the data boundary - `return tools.ns.tool(...)` works - // without an explicit await, exactly as in JS. + // The implicit async body adopts returned promises before copy-out. if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) return value - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } - // Eagerly starts a tool call in the execution's promise scope (so timeout and teardown - // interrupt it) gated by the concurrency semaphore, and wraps the fiber in a - // first-class promise value. `startImmediately` makes the runtime admit the call - charging - // the tool-call budget and firing onToolCallStart - at the call site, before any await. + // Fork at the call site so admission and hooks occur when the call is made. private createToolCallPromise( path: ReadonlyArray, args: Array, @@ -794,16 +246,12 @@ class Interpreter { return this.promises.create(effect) } - // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch - // observes it exactly like a synchronous throw at the await site. Settlement is idempotent - // (fiber exits replay), so awaiting the same promise repeatedly never re-runs the call. + // Fiber exits make settlement idempotent; yielding prevents inline continuation. private settlePromise(promise: SandboxPromise): Effect.Effect { const promises = this.promises return Effect.suspend(() => { promises.markObserved(promise) - return Effect.flatMap(promises.await(promise), (exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ) + return Effect.flatMap(promises.await(promise), (exit) => Effect.andThen(Effect.yieldNow, exit)) }) } @@ -846,14 +294,14 @@ class Interpreter { case "EmptyStatement": return Effect.succeed({ kind: "none" }) case "FunctionDeclaration": - return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + return Effect.succeed({ kind: "none" }) default: throw unsupportedSyntax(node.type, node) } } private evaluateBlock(node: AstNode): Effect.Effect { - this.pushScope() + this.scopes.push() const self = this return Effect.gen(function* () { const body = getArray(node, "body") @@ -869,7 +317,7 @@ class Interpreter { } return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } private createFunction(node: AstNode): CodeModeFunction { @@ -884,18 +332,16 @@ class Interpreter { return new CodeModeFunction( getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), getNode(node, "body"), - this.scopes.slice(), + this.scopes.capture(), node.async === true, ) } - // Function declarations are hoisted: bound in their scope before the body runs, so a - // program can call a helper defined further down (matching JavaScript). private hoistFunctions(statements: Array): void { for (const statementValue of statements) { if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue const node = statementValue as AstNode - this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + this.scopes.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) } } @@ -915,7 +361,7 @@ class Interpreter { private evaluateSwitchStatement(node: AstNode): Effect.Effect { const self = this - this.pushScope() + this.scopes.push() return Effect.gen(function* () { const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) if (containsOpaqueReference(discriminant)) { @@ -957,7 +403,7 @@ class Interpreter { } } return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } private evaluateWhileStatement(node: AstNode): Effect.Effect { @@ -980,7 +426,6 @@ class Interpreter { if (result.kind === "return") { return result } - } return { kind: "none" } satisfies StatementResult @@ -1007,7 +452,6 @@ class Interpreter { if (result.kind === "return") { return result } - } while (yield* self.evaluateExpression(testNode)) return { kind: "none" } satisfies StatementResult @@ -1015,7 +459,7 @@ class Interpreter { } private evaluateForStatement(node: AstNode): Effect.Effect { - this.pushScope() + this.scopes.push() const self = this return Effect.gen(function* () { const initNode = getOptionalNode(node, "init") @@ -1033,24 +477,21 @@ class Interpreter { const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" - ? Array.from(self.currentScope().keys()) + ? Array.from(self.scopes.current().keys()) : [] while (testNode ? yield* self.evaluateExpression(testNode) : true) { - let iterationScope: Map | undefined - if (perIterationBindings.length > 0) { - iterationScope = new Map( - perIterationBindings.map((name) => { - const binding = self.currentScope().get(name)! - return [name, { ...binding }] - }), - ) - self.scopes.push(iterationScope) - } + const iterationScope = + perIterationBindings.length > 0 + ? new Map( + perIterationBindings.map((name): [string, Binding] => [name, { ...self.scopes.current().get(name)! }]), + ) + : undefined + if (iterationScope) self.scopes.push(iterationScope) const result = yield* self.evaluateStatement(bodyNode).pipe( Effect.ensuring( Effect.sync(() => { - if (iterationScope) self.popScope() + if (iterationScope) self.scopes.pop() }), ), ) @@ -1064,7 +505,7 @@ class Interpreter { } if (iterationScope) { - const loopScope = self.currentScope() + const loopScope = self.scopes.current() for (const name of perIterationBindings) { loopScope.set(name, { ...iterationScope.get(name)! }) } @@ -1080,7 +521,7 @@ class Interpreter { } return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } private evaluateForOfStatement(node: AstNode): Effect.Effect { @@ -1094,9 +535,7 @@ class Interpreter { const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") - // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] - // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). - const iterable = Array.isArray(right) ? right : spreadItems(right) + const iterable = spreadItems(right) if (iterable === undefined) { throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) } @@ -1125,7 +564,7 @@ class Interpreter { for (const value of iterable) { if (declaration) { - self.pushScope() + self.scopes.push() yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) } else if (assignment) { yield* self.assignPattern(assignment, value, left) @@ -1134,7 +573,7 @@ class Interpreter { const result = yield* self.evaluateStatement(body).pipe( Effect.ensuring( Effect.sync(() => { - if (declaration) self.popScope() + if (declaration) self.scopes.pop() }), ), ) @@ -1156,11 +595,6 @@ class Interpreter { }) } - // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool - // references: plain data objects enumerate their own keys, arrays their index strings (plus - // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in - // JS), and a tool reference the namespace/tool names at its path in the host tool tree. - // Returns undefined for everything else so callers can raise a contextual error. private enumerableKeys(value: unknown): Array | undefined { if (value instanceof ToolReference) { return [...this.toolKeys(value.path)] @@ -1181,12 +615,6 @@ class Interpreter { const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") - // Keys are snapshotted up front (mutation during iteration is safe): plain objects - // enumerate their own keys, arrays their index strings, and tool references the - // namespace/tool names at that node - the same enumeration Object.keys performs. - // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather - // than real JS's surprising behavior (indices for strings, zero iterations for - // Maps/Sets/null): the hint points at the constructs that do what the program means. const keys = self.enumerableKeys(right) if (keys === undefined) { throw new InterpreterRuntimeError( @@ -1214,16 +642,16 @@ class Interpreter { for (const key of keys) { if (declaration) { - self.pushScope() + self.scopes.push() yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) } else if (assignmentName) { - self.setIdentifierValue(assignmentName, key, left) + self.scopes.set(assignmentName, key, left) } const result = yield* self.evaluateStatement(body).pipe( Effect.ensuring( Effect.sync(() => { - if (declaration) self.popScope() + if (declaration) self.scopes.pop() }), ), ) @@ -1282,15 +710,13 @@ class Interpreter { return Effect.failCause(cause) } - // The program sees a plain { message } error (or the thrown value itself) - see - // caughtErrorValue, shared with Promise.allSettled rejection reasons. const caught = caughtErrorValue(Cause.squash(cause)) const parameter = getOptionalNode(handler, "param") - self.pushScope() + self.scopes.push() return Effect.gen(function* () { if (parameter) yield* self.declarePattern(parameter, caught, true, handler) return yield* self.evaluateStatement(getNode(handler, "body")) - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) }, onSuccess: Effect.succeed, }) @@ -1342,11 +768,10 @@ class Interpreter { const self = this return Effect.gen(function* () { if (pattern.type === "Identifier") { - self.declare(getString(pattern, "name"), value, mutable, node) + self.scopes.declare(getString(pattern, "name"), value, mutable, node) return } - // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. if (pattern.type === "AssignmentPattern") { const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) @@ -1366,7 +791,6 @@ class Interpreter { for (const propertyValue of getArray(pattern, "properties")) { const property = asNode(propertyValue, "properties") - // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. if (property.type === "RestElement") { const rest: SafeObject = Object.create(null) as SafeObject for (const [key, item] of Object.entries(value as SafeObject)) { @@ -1403,7 +827,6 @@ class Interpreter { for (const [index, item] of getArray(pattern, "elements").entries()) { if (item === null) continue const element = asNode(item, `elements[${index}]`) - // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). if (element.type === "RestElement") { yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) break @@ -1421,7 +844,7 @@ class Interpreter { const self = this return Effect.gen(function* () { if (pattern.type === "Identifier") { - self.setIdentifierValue(getString(pattern, "name"), value, pattern) + self.scopes.set(getString(pattern, "name"), value, pattern) return } @@ -1498,8 +921,6 @@ class Interpreter { private evaluateExpression(node: AstNode): Effect.Effect { switch (node.type) { case "Literal": { - // A regex literal parses as a Literal node carrying { pattern, flags }; construct the - // sandbox regex from those (the host `value` instance is never exposed). const regex = node.regex if (isRecord(regex) && typeof regex.pattern === "string") { return Effect.sync(() => @@ -1509,7 +930,7 @@ class Interpreter { return Effect.sync(() => boundedData(node.value, "Literal")) } case "Identifier": - return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + return Effect.sync(() => this.scopes.get(getString(node, "name"), node)) case "BinaryExpression": return this.evaluateBinaryExpression(node) case "LogicalExpression": @@ -1550,11 +971,10 @@ class Interpreter { case "UpdateExpression": return this.evaluateUpdateExpression(node) case "AwaitExpression": { - // `await` resolves a promise value; awaiting anything else is a passthrough no-op, - // matching real JS semantics for non-thenables. + // Await always suspends, including for plain values. const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value), + value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value), ) } case "NewExpression": @@ -1573,19 +993,12 @@ class Interpreter { const argNodes = getArray(node, "arguments") const self = this if (name === "Promise") { - throw new InterpreterRuntimeError( - "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], + return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => + constructPromise(self.runner, self.promises, args[0], node), ) } if (errorConstructors.has(name)) { - return Effect.gen(function* () { - const arg = - argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined - return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) - }) + return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node)) } if (valueConstructors.has(name)) { return Effect.gen(function* () { @@ -1618,7 +1031,6 @@ class Interpreter { if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) return new SandboxDate(Number.NaN) } - // new Date(year, month, day?, hours?, ...) - local-time component form. const parts = args.map((arg) => coerceToNumber(arg)) return new SandboxDate(new Date(...(parts as [number, number])).getTime()) } @@ -1638,9 +1050,6 @@ class Interpreter { try { return new SandboxRegExp(pattern, flags) } catch (error) { - // Say which part was rejected and how to fix it, instead of passing the engine - // message through bare. A flags failure names the flags; a pattern failure gets the - // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). const reason = regexFailureReason(error) throw new InterpreterRuntimeError( /flag/i.test(reason) @@ -1758,29 +1167,17 @@ class Interpreter { return Effect.gen(function* () { const lhs = yield* self.evaluateExpression(getNode(node, "left")) const rhs = yield* self.evaluateExpression(getNode(node, "right")) - // Like `typeof`, `instanceof` observes any value without coercing it (a promise or - // function operand is a legitimate question, not an error), so it is handled before - // the data-only operand check. if (operator === "instanceof") return instanceofValue(lhs, rhs, node) return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") }) } - /** - * Applies a binary operator to two already-evaluated operands with CodeMode's coercion - * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave - * exactly like `x = x op y`, coercion included). - */ private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") } - // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host - // "No default value" TypeError when an operator coerces them. Coerce to their JS string - // form first (as String(x) / template literals do) so operators behave like JavaScript. - // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value - // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. - // Identity (=== / !==) and the right operand of `in` keep their raw object value. + // Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects. + // Dates use string coercion for `+` and epoch time elsewhere. const coerceOperand = (operand: unknown): unknown => { if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand @@ -1801,7 +1198,6 @@ class Interpreter { return (l as number) % (r as number) case "**": return (l as number) ** (r as number) - // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. case "==": return bothObjects ? lhs === rhs : l == r case "===": @@ -1834,7 +1230,7 @@ class Interpreter { if (rhs === null || typeof rhs !== "object") { throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) } - // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + // Never expose properties inherited from host prototypes. return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) @@ -1857,23 +1253,16 @@ class Interpreter { private evaluateUnaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") const argument = getNode(node, "argument") - // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so - // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before - // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. - if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + // Undeclared names short-circuit, but declared TDZ bindings must still throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(getString(argument, "name"))) { return Effect.succeed("undefined") } return Effect.map(this.evaluateExpression(argument), (value) => { - // `typeof` and `!` never throw in JS - they observe any value (functions and runtime - // references included) without coercing it, so feature detection and negation work. if (operator === "typeof") return typeofValue(value) if (operator === "!") return !value if (containsOpaqueReference(value)) { throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") } - // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value - // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to - // their JS string form first (see evaluateBinaryExpression). const operand = value instanceof SandboxDate ? value.time @@ -1914,16 +1303,16 @@ class Interpreter { if (left.type === "Identifier") { const name = getString(left, "name") if (operator !== "=") { - const current = self.getIdentifierValue(name, left) + const current = self.scopes.get(name, left) const rightValue = yield* self.evaluateExpression(getNode(node, "right")) const next = boundedData( self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", ) - return self.setIdentifierValue(name, next, left) + return self.scopes.set(name, next, left) } const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + return self.scopes.set(name, rightValue, left) } if (left.type === "MemberExpression") { return yield* self.modifyMember(left, (current) => @@ -1952,14 +1341,13 @@ class Interpreter { if (left.type === "Identifier") { const name = getString(left, "name") return Effect.gen(function* () { - const current = self.getIdentifierValue(name, left) + const current = self.scopes.get(name, left) if (!shouldAssign(current)) return current const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - return self.setIdentifierValue(name, rightValue, left) + return self.scopes.set(name, rightValue, left) }) } if (left.type === "MemberExpression") { - // Resolve the member exactly once; evaluate the RHS only if we actually assign. return self.modifyMember(left, (current) => shouldAssign(current) ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ @@ -1987,9 +1375,9 @@ class Interpreter { if (argument.type === "Identifier") { return Effect.sync(() => { const name = getString(argument, "name") - const current = Number(this.getIdentifierValue(name, argument)) + const current = Number(this.scopes.get(name, argument)) const next = current + increment - this.setIdentifierValue(name, next, argument) + this.scopes.set(name, next, argument) return prefix ? next : current }) } @@ -2019,17 +1407,19 @@ class Interpreter { if (callable instanceof ToolReference) { if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) - // An un-awaited tool call is a first-class promise value; the call itself starts now. return yield* self.createToolCallPromise(callable.path, args) } if (callable instanceof PromiseMethodReference) { - return yield* self.invokePromiseMethod(callable, args, node) + return yield* invokePromiseMethod(self.runner, self.promises, callable, args, node) + } + if (callable instanceof PromiseInstanceMethodReference) { + return yield* invokePromiseInstanceMethod(self.runner, self.promises, callable, args, node) } if (callable instanceof CodeModeFunction) { return yield* self.invokeFunction(callable, args) } if (callable instanceof IntrinsicReference) { - return yield* self.invokeIntrinsic(callable, args, node) + return yield* invokeIntrinsic(self.runner, callable, args, node) } if (callable instanceof GlobalMethodReference) { if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) @@ -2050,24 +1440,26 @@ class Interpreter { if (callable instanceof UriFunction) { return invokeUriFunction(callable, args, node) } - // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. + if (callable instanceof SearchFunction) { + return yield* self.invokeSearch(args) + } if (callable instanceof ErrorConstructorReference) { - return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) + return constructErrorValue(callable.name, args, node) + } + if (callable instanceof PromiseCapabilityFunction) { + callable.settle(args[0]) + return undefined } throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) }) } - // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate - // namespace/tool names from the host tool tree - the discovery idiom a model reaches for - // first. Other Object helpers fail with a pointer at the working idioms instead of a generic - // plain-data message. private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { if (name === "keys") { return boundedData(this.enumerableKeys(ref)!, "Object.keys result") } throw new InterpreterRuntimeError( - `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`, node, "InvalidDataValue", ) @@ -2076,126 +1468,10 @@ class Interpreter { private invokeConsole(name: string, args: Array, node: AstNode): undefined { if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) - this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) + this.logs.push(formatConsoleMessage(name, args)) return undefined } - private formatConsoleMessage(name: string, args: Array, node: AstNode): string { - if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) - if (name === "table") return this.formatConsoleTable(args[0], args[1], node) - const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" - return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` - } - - // Console arguments format deeply and totally: values render as a debugger would show them - // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox - // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], - // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, - // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles - // render "[Circular]" and extreme depth degrades to "...". - private formatConsoleArgument(value: unknown): string { - if (value === undefined) return "undefined" - // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). - if (typeof value === "string") return value - return this.formatConsoleValue(value, new Set(), 0) - } - - private formatConsoleValue(value: unknown, seen: Set, depth: number): string { - // Nested undefined renders as null, matching what JSON boundary output would show. - if (value === null || value === undefined) return "null" - if (typeof value === "string") return JSON.stringify(value) - // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. - if (typeof value === "number" || typeof value === "boolean") return String(value) - if (typeof value !== "object") return String(value) - if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" - if (value instanceof SandboxDate) return coerceToString(value) - if (value instanceof SandboxRegExp) return coerceToString(value) - if (value instanceof SandboxURL) return coerceToString(value) - if (value instanceof SandboxURLSearchParams) return coerceToString(value) - if (depth > MAX_CONSOLE_DEPTH) return "..." - if (seen.has(value)) return "[Circular]" - if (value instanceof SandboxMap) { - seen.add(value) - try { - const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) - return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` - } finally { - seen.delete(value) - } - } - if (value instanceof SandboxSet) { - seen.add(value) - try { - return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` - } finally { - seen.delete(value) - } - } - if (isRuntimeReference(value)) return "[CodeMode reference]" - seen.add(value) - try { - if (Array.isArray(value)) { - return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` - } - return `{${Object.entries(value) - .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) - .join(",")}}` - } finally { - seen.delete(value) - } - } - - private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { - if (value === undefined) return "undefined" - // Sandbox values are legitimate table data (cells render their friendly forms); only - // truly opaque references (functions, tools, promises) collapse to the marker. - if (containsOpaqueReference(value)) return "[CodeMode reference]" - const data = boundedData(value, "console.table argument") - const columns = this.consoleTableColumns(columnsArgument, node) - const rows = this.consoleTableRows(data, columns) - const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) - const header = ["(index)", ...keys].join("\t") - return [ - header, - ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), - ].join("\n") - } - - private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { - if (value === undefined) return undefined - if (containsRuntimeReference(value)) return undefined - const columns = copyOut(copyIn(value, "console.table columns"), true) - return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined - } - - private consoleTableRows( - data: unknown, - columns: ReadonlyArray | undefined, - ): Array<{ readonly index: string; readonly values: Record }> { - if (Array.isArray(data)) { - return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) - } - if (data !== null && typeof data === "object" && !isSandboxValue(data)) { - return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) - } - return [{ index: "0", values: { Value: data } }] - } - - private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { - if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { - const source = value as Record - if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) - return Object.fromEntries(Object.entries(source)) - } - return { Value: value } - } - - private formatConsoleTableCell(value: unknown): string { - if (value === undefined) return "" - if (typeof value === "string") return value - return this.formatConsoleValue(value, new Set(), 0) - } - private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { const self = this return Effect.gen(function* () { @@ -2219,124 +1495,19 @@ class Interpreter { }) } - // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable - // collection) mixing promise values and plain data - built inline, beforehand, via spread, - // whatever - because tool calls already run eagerly on their own fibers. Each combinator - // returns a real promise whose join runs on its own scope-owned fiber, observing member - // settlements; the concurrency cap stays where the work is: the fork semaphore. - private invokePromiseMethod( - ref: PromiseMethodReference, - args: Array, - node: AstNode, - ): Effect.Effect { - if (ref.name === "resolve") { - // Promise.resolve of a promise is that promise (JS flattens); anything else is a - // promise already fulfilled with the value. Pre-settled values still fork a scope-owned - // fiber so every promise shares one lifecycle (an abandoned reject is reported, teardown - // is uniform). - const value = args[0] - return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value)) - } - if (ref.name === "reject") { - return this.createPromise(Effect.fail(new ProgramThrow(args[0]))) - } - - const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) - if (items === undefined) { - return this.createPromise( - Effect.fail( - new InterpreterRuntimeError( - `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, - node, - ), - ), - ) - } - - // JS makes combinator members "handled" synchronously at the call - their rejections - // belong to the aggregate from this moment, even ones settling before it runs. - for (const item of items) { - if (item instanceof SandboxPromise) this.promises.markObserved(item) - } - - switch (ref.name) { - case "all": { - // Each observation re-raises its member's failure, so Effect.all rejects on the first - // failure without waiting for the rest and preserves input order when all fulfill. - // Its failure-time interruption only unsubscribes the sibling waiters: the underlying - // fibers stay execution-owned and keep running, as in JS. - const observations = items.map((item) => - item instanceof SandboxPromise - ? Effect.flatMap(this.promises.await(item), (exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ) - : Effect.succeed(item), - ) - return this.createPromise(Effect.all(observations, { concurrency: "unbounded" })) - } - case "allSettled": { - const observations = items.map((item) => - item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)), - ) - return this.createPromise( - Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const exit = yield* observation - if (Exit.isSuccess(exit)) { - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), - ) - continue - } - if (Cause.hasInterruptsOnly(exit.cause)) { - // Execution teardown (timeout/host interruption), not a program-level rejection. - return yield* Effect.failCause(exit.cause) - } - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(Cause.squash(exit.cause)), - }), - ) - } - return outcomes - }), - ) - } - case "race": { - if (items.length === 0) { - return this.createPromise( - Effect.fail( - new InterpreterRuntimeError( - "Promise.race([]) would never settle; provide at least one promise or value.", - node, - ), - ), - ) - } - const observations = items.map((item) => - item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)), - ) - // First settlement (fulfilled OR rejected) wins; losing work stays execution-owned - // and is interrupted at normal completion (already observed) or by teardown. - return this.createPromise( - Effect.flatMap(Effect.raceAll(observations), (exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ), - ) - } - } - } - private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits) - invocation.scopes = [...fn.capturedScopes, new Map()] + const invocation = new Interpreter( + this.invokeTool, + this.invokeSearch, + this.toolKeys, + this.promises, + this.logs, + this.callPermits, + ) + invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()]) const run = Effect.gen(function* () { - // Seed every parameter name into the scope as a TDZ slot first, so a default that - // references another parameter resolves to that (uninitialized) param rather than - // silently falling through to an outer binding of the same name - matching JS. - const paramScope = invocation.currentScope() + // Seed all parameters first so defaults cannot fall through to same-named outer bindings. + const paramScope = invocation.scopes.current() for (const parameter of fn.parameters) { for (const name of collectPatternNames(parameter)) { paramScope.set(name, { mutable: true, value: undefined, initialized: false }) @@ -2358,580 +1529,21 @@ class Interpreter { return yield* invocation.evaluateExpression(fn.body) }) if (!fn.async) return run - return this.createPromise( - Effect.flatMap(run, (value) => - value instanceof SandboxPromise ? invocation.settlePromise(value) : Effect.succeed(value), - ), - ) - } - - private invokeIntrinsic( - ref: IntrinsicReference, - args: Array, - node: AstNode, - ): Effect.Effect { - if (typeof ref.receiver === "string") { - if ( - (ref.name === "replace" || ref.name === "replaceAll") && - (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction) - ) { - return this.invokeStringReplacer(ref.receiver, ref.name, args, node) - } - return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) - } - if (typeof ref.receiver === "number") { - return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) - } - if (Array.isArray(ref.receiver)) { - return this.invokeArrayMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxDate) { - return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) - } - if (ref.receiver instanceof SandboxRegExp) { - return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) - } - if (ref.receiver instanceof SandboxMap) { - return this.invokeMapMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxSet) { - return this.invokeSetMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxURL) { - return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node)) - } - if (ref.receiver instanceof SandboxURLSearchParams) { - return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node) - } - throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) - } - - private invokeStringReplacer( - value: string, - name: "replace" | "replaceAll", - args: Array, - node: AstNode, - ): Effect.Effect { - const apply = this.applyCollectionCallback(args[1], `String.${name}`, node) - const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] - const collect = (...callbackArgs: Array): string => { - const match = callbackArgs[0] - const groups = callbackArgs[callbackArgs.length - 1] - const hasGroups = groups !== null && typeof groups === "object" - const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] - if (typeof match !== "string" || typeof offset !== "number") { - throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) - } - if (hasGroups) { - const safeGroups: SafeObject = Object.create(null) as SafeObject - for (const [key, group] of Object.entries(groups)) { - if (!isBlockedMember(key)) safeGroups[key] = group - } - callbackArgs[callbackArgs.length - 1] = safeGroups - } - matches.push({ match, offset, args: callbackArgs }) - return match - } - - const pattern = args[0] - if (pattern instanceof SandboxRegExp) { - if (name === "replaceAll" && !pattern.regex.global) { - throw new InterpreterRuntimeError( - `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`, - node, - ) - } - if (name === "replace") value.replace(pattern.regex, collect) - else value.replaceAll(pattern.regex, collect) - } else { - if (typeof pattern !== "string") { - throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node) - } - if (name === "replace") value.replace(pattern, collect) - else value.replaceAll(pattern, collect) - } - - const self = this - return Effect.gen(function* () { - const output: Array = [] - let end = 0 - for (const match of matches) { - const replacement = yield* apply(match.args) - const resolved = - args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise - ? yield* self.settlePromise(replacement) - : replacement - output.push( - value.slice(end, match.offset), - coerceToString(boundedData(resolved, `String.${name} replacer result`)), - ) - end = match.offset + match.match.length - } - output.push(value.slice(end)) - return boundedData(output.join(""), `String.${name} result`) - }) - } - - // Runs a collection callback accepting a user function or supported builtin callable, - // mirroring the array-method callback contract. - private applyCollectionCallback( - callback: unknown, - name: string, - node: AstNode, - ): (args: Array) => Effect.Effect { - if ( - !(callback instanceof CodeModeFunction) && - !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) - ) { - throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) - } - return (callbackArgs) => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : callback instanceof UriFunction - ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : this.invokeFunction(callback, callbackArgs) - } - - private invokeMapMethod( - target: SandboxMap, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - switch (name) { - case "get": - return Effect.succeed(target.map.get(args[0])) - case "has": - return Effect.succeed(target.map.has(args[0])) - case "set": - return Effect.sync(() => { - target.map.set(args[0], args[1]) - return target - }) - case "delete": - return Effect.sync(() => target.map.delete(args[0])) - case "clear": - return Effect.sync(() => { - target.map.clear() - return undefined - }) - case "keys": - return Effect.sync(() => Array.from(target.map.keys())) - case "values": - return Effect.sync(() => Array.from(target.map.values())) - case "entries": - return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) - case "forEach": { - const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) - return Effect.gen(function* () { - // Snapshot iteration, matching the array-method callback contract. - for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeSetMethod( - target: SandboxSet, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - switch (name) { - case "has": - return Effect.succeed(target.set.has(args[0])) - case "add": - return Effect.sync(() => { - target.set.add(args[0]) - return target - }) - case "delete": - return Effect.sync(() => target.set.delete(args[0])) - case "clear": - return Effect.sync(() => { - target.set.clear() - return undefined - }) - case "keys": - case "values": - return Effect.sync(() => Array.from(target.set.values())) - case "entries": - return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) - case "forEach": { - const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) - return Effect.gen(function* () { - for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeURLSearchParamsMethod( - target: SandboxURLSearchParams, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`) - const requireArgs = (count: number): void => { - if (args.length < count) { - throw new InterpreterRuntimeError( - `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`, - node, - ).as("TypeError") - } - } - switch (name) { - case "append": { - requireArgs(2) - return Effect.sync(() => { - target.params.append(arg(0), arg(1)) - return undefined - }) - } - case "delete": { - requireArgs(1) - return Effect.sync(() => { - if (args[1] !== undefined) target.params.delete(arg(0), arg(1)) - else target.params.delete(arg(0)) - return undefined - }) - } - case "get": - requireArgs(1) - return Effect.sync(() => target.params.get(arg(0))) - case "getAll": - requireArgs(1) - return Effect.sync(() => target.params.getAll(arg(0))) - case "has": - requireArgs(1) - return Effect.sync(() => - args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)), - ) - case "set": { - requireArgs(2) - return Effect.sync(() => { - target.params.set(arg(0), arg(1)) - return undefined - }) - } - case "sort": - return Effect.sync(() => { - target.params.sort() - return undefined - }) - case "keys": - return Effect.sync(() => Array.from(target.params.keys())) - case "values": - return Effect.sync(() => Array.from(target.params.values())) - case "entries": - return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) - case "toString": - return Effect.sync(() => target.params.toString()) - case "forEach": { - requireArgs(1) - const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node) - return Effect.gen(function* () { - for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeArrayMethod( - target: Array, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - const optNumber = (value: unknown, label: string): number | undefined => { - if (value === undefined) return undefined - if (typeof value !== "number") - throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) - return value - } - switch (name) { - case "join": { - if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { - throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) - } - const input = boundedData(target, "Array.join input") as Array - return Effect.succeed( - input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), - ) - } - case "includes": - if (args.length === 0 || args.length > 2) - throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) - return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) - case "indexOf": - return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) - case "lastIndexOf": - return Effect.succeed( - args[1] === undefined - ? target.lastIndexOf(args[0]) - : target.lastIndexOf(args[0], optNumber(args[1], "start index")), - ) - case "at": - return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) - case "slice": - return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) - case "concat": - return Effect.succeed(target.concat(...args)) - case "flat": - return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) - case "reverse": - return Effect.succeed(target.reverse()) - case "sort": - return Effect.map(this.sortArray(target, args[0], node), (sorted) => { - target.splice(0, target.length, ...sorted) - return target - }) - case "toSorted": - return this.sortArray(target, args[0], node) - case "toReversed": - return Effect.succeed([...target].reverse()) - case "with": { - const index = optNumber(args[0], "index") ?? 0 - const resolved = index < 0 ? target.length + index : index - if (resolved < 0 || resolved >= target.length) { - throw new InterpreterRuntimeError("Array.with index is out of range.", node) - } - const copied = [...target] - copied[resolved] = args[1] - return Effect.succeed(copied) - } - case "push": { - // Validate before mutating (so no rollback is needed): inserting a container into - // itself would create a cycle no later walk could survive. - for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) - target.push(...args) - return Effect.succeed(target.length) - } - case "unshift": { - for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) - target.unshift(...args) - return Effect.succeed(target.length) - } - case "pop": - return Effect.succeed(target.pop()) - case "shift": - return Effect.succeed(target.shift()) - case "splice": { - // Mutates in place and returns the removed elements, exactly like JS: one argument - // removes to the end, an undefined delete count removes nothing. - if (args.length === 0) return Effect.succeed(target.splice(0, 0)) - const start = optNumber(args[0], "start") ?? 0 - if (args.length === 1) return Effect.succeed(target.splice(start)) - const deleteCount = optNumber(args[1], "delete count") ?? 0 - const inserted = args.slice(2) - for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) - return Effect.succeed(target.splice(start, deleteCount, ...inserted)) - } - case "fill": { - this.rejectCircularInsertion(target, args[0], "Array.fill result", node) - return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) - } - case "copyWithin": - return Effect.succeed( - target.copyWithin( - optNumber(args[0], "target index") ?? 0, - optNumber(args[1], "start") ?? 0, - optNumber(args[2], "end"), - ), - ) - // keys/values/entries return arrays (not iterators), matching the Map/Set convention; - // they work with for...of and spread either way. - case "keys": - return Effect.succeed(Array.from(target.keys())) - case "values": - return Effect.succeed([...target]) - case "entries": - return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) - } - - const callback = args[0] - if ( - !(callback instanceof CodeModeFunction) && - !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) - ) { - throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) - } - const self = this - // Accept a user function or supported builtin callable, so idioms such as - // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins - // are synchronous; only CodeModeFunctions can await tool calls. - const apply = (callbackArgs: Array): Effect.Effect => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : callback instanceof UriFunction - ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : self.invokeFunction(callback, callbackArgs) - return Effect.gen(function* () { - // Capture the initial length, but read the receiver live so callbacks observe mutations - // without visiting elements appended after iteration begins. - const length = target.length - switch (name) { - case "map": { - const values: Array = [] - values.length = length - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - values[index] = yield* apply([target[index], index, target]) - } - return values - } - case "flatMap": { - const values: Array = [] - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - const mapped = yield* apply([target[index], index, target]) - if (Array.isArray(mapped)) values.push(...mapped) - else values.push(mapped) - } - return values - } - case "filter": { - const values: Array = [] - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - const item = target[index] - if (yield* apply([item, index, target])) values.push(item) - } - return values - } - case "find": - for (let index = 0; index < length; index += 1) { - const item = target[index] - if (yield* apply([item, index, target])) return item - } - return undefined - case "findIndex": - for (let index = 0; index < length; index += 1) { - if (yield* apply([target[index], index, target])) return index - } - return -1 - case "some": - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - if (yield* apply([target[index], index, target])) return true - } - return false - case "every": - for (let index = 0; index < length; index += 1) { - if (!(index in target)) continue - if (!(yield* apply([target[index], index, target]))) return false - } - return true - case "forEach": - for (let index = 0; index < length; index += 1) { - if (index in target) yield* apply([target[index], index, target]) - } - return undefined - case "reduce": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = 0 - } else { - if (length === 0) - throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) - accumulator = target[0] - start = 1 - } - for (let index = start; index < length; index += 1) { - if (!(index in target)) continue - accumulator = yield* apply([accumulator, target[index], index, target]) - } - return accumulator - } - case "reduceRight": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = length - 1 - } else { - if (length === 0) - throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) - accumulator = target[length - 1] - start = length - 2 - } - for (let index = start; index >= 0; index -= 1) { - if (!(index in target)) continue - accumulator = yield* apply([accumulator, target[index], index, target]) - } - return accumulator - } - case "findLast": - for (let index = length - 1; index >= 0; index -= 1) { - if (yield* apply([target[index], index, target])) return target[index] - } - return undefined - case "findLastIndex": - for (let index = length - 1; index >= 0; index -= 1) { - if (yield* apply([target[index], index, target])) return index - } - return -1 - } - throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) - }) - } - - private sortArray( - target: Array, - comparator: unknown, - node: AstNode, - ): Effect.Effect, unknown, R> { - if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { - throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) - } - if (!(comparator instanceof CodeModeFunction)) { - return Effect.sync(() => - [...target].sort((a, b) => { - const left = coerceToString(a) - const right = coerceToString(b) - return left < right ? -1 : left > right ? 1 : 0 + // The initial yield assigns `box.own` before the body can self-resolve. + const box: { own?: SandboxPromise } = {} + return Effect.map( + this.createPromise( + Effect.flatMap(run, (value) => { + if (!(value instanceof SandboxPromise)) return Effect.succeed(value) + if (value === box.own) return Effect.fail(selfResolutionError()) + return invocation.settlePromise(value) }), - ) - } - const self = this - const mergeSort = (items: Array): Effect.Effect, unknown, R> => { - if (items.length <= 1) return Effect.succeed(items) - const midpoint = Math.floor(items.length / 2) - return Effect.gen(function* () { - const left = yield* mergeSort(items.slice(0, midpoint)) - const right = yield* mergeSort(items.slice(midpoint)) - const merged: Array = [] - let leftIndex = 0 - let rightIndex = 0 - while (leftIndex < left.length && rightIndex < right.length) { - // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host - // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. - const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) - if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) - else merged.push(right[rightIndex++]) - } - return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] - }) - } - // Per spec, undefined elements sort to the end and the comparator is never called on them. - const defined = target.filter((item) => item !== undefined) - const undefinedCount = target.length - defined.length - return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) + ), + (promise) => { + box.own = promise + return promise + }, + ) } private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { @@ -2944,9 +1556,6 @@ class Interpreter { if (property.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(property, "argument")) - // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common - // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values - // have no own enumerable properties in JS, so they are no-ops too. if (spread === null || spread === undefined || isSandboxValue(spread)) continue if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { throw new InterpreterRuntimeError( @@ -3046,8 +1655,6 @@ class Interpreter { if (index < expressions.length) { const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) - // The preserving checkpoint keeps sandbox values intact, so coerceToString renders - // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. output += coerceToString(boundedData(raw, "Template interpolation")) } } @@ -3063,10 +1670,6 @@ class Interpreter { } private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { - // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation - // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). - // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) - // short-circuit and are handled by evaluateLogicalAssignment before reaching here. if (!compoundOperators.has(operator)) { throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) } @@ -3079,6 +1682,7 @@ class Interpreter { | MemberReference | ToolReference | PromiseMethodReference + | PromiseInstanceMethodReference | IntrinsicReference | GlobalMethodReference | ComputedValue @@ -3115,7 +1719,7 @@ class Interpreter { return new PromiseMethodReference(key as PromiseMethodName) } throw new InterpreterRuntimeError( - `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, + `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`, propertyNode, ) } @@ -3138,20 +1742,14 @@ class Interpreter { if (typeof key === "number") return new ComputedValue(objectValue[key]) if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), - // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string - // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a - // real string still reaches here.) Only the method allowlist above yields callables. return new ComputedValue(undefined) } if (typeof objectValue === "number") { if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. return new ComputedValue(undefined) } - // Number / String expose a small allowlist of statics; everything else stays opaque. if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { if (objectValue.name === "Number" && numberConstants.has(key)) { return new ComputedValue((Number as unknown as Record)[key]) @@ -3160,8 +1758,6 @@ class Interpreter { if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) } - // Sandbox value types expose their method/property allowlists; any other key reads as - // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. if (objectValue instanceof SandboxDate) { if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) return new ComputedValue(undefined) @@ -3199,20 +1795,13 @@ class Interpreter { return new ComputedValue(undefined) } - // Any property access on a promise is a confused program (`p.then(...)`, `p.value`); - // reading `undefined` here would hide the missing await, so both paths get an explicit, - // await-hinting error instead of the forgiving unknown-property fallthrough. + // Reject unknown promise properties so a missing await cannot hide. if (objectValue instanceof SandboxPromise) { if (key === "then" || key === "catch" || key === "finally") { - throw new InterpreterRuntimeError( - `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, - propertyNode, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) + return new PromiseInstanceMethodReference(objectValue, key) } throw new InterpreterRuntimeError( - "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", + "This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.", objectNode, "InvalidDataValue", ) @@ -3241,13 +1830,9 @@ class Interpreter { typeof key !== "number" && !/^\d+$/.test(key) ) { - // Own non-index properties read through (match results carry index/groups); like JS, - // they are readable in place and dropped by JSON at data boundaries. if (typeof key === "string" && Object.hasOwn(objectValue, key)) { return new ComputedValue((objectValue as Record & Array)[key]) } - // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), - // instead of throwing - so defensive access under optional chaining behaves as expected. return new ComputedValue(undefined) } return { target: objectValue, key } @@ -3265,6 +1850,7 @@ class Interpreter { reference === undefined || reference instanceof ToolReference || reference instanceof PromiseMethodReference || + reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || reference instanceof GlobalMethodReference ) @@ -3286,9 +1872,7 @@ class Interpreter { return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) } - // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression - // runs once), then lets `compute` decide whether to write - enabling compound assignment, - // updates, plain writes, and short-circuiting logical assignment to share one safe path. + // Resolve side-effecting object and key expressions exactly once. private modifyMember( node: AstNode, compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, @@ -3302,6 +1886,7 @@ class Interpreter { reference === undefined || reference instanceof ToolReference || reference instanceof PromiseMethodReference || + reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || reference instanceof GlobalMethodReference ) { @@ -3325,24 +1910,6 @@ class Interpreter { }) } - // Rejects inserting a value that (transitively) contains the container it is being inserted - // into - the mutation that would create a circular structure no later walk could survive. - private rejectCircularInsertion( - container: object, - value: unknown, - label: string, - node: AstNode, - seen = new Set(), - ): void { - if (value === container) - throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return - seen.add(value) - const items = Array.isArray(value) ? value : Object.values(value) - for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) - seen.delete(value) - } - private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { if (Array.isArray(reference.target)) { const target = reference.target @@ -3354,7 +1921,7 @@ class Interpreter { "InvalidDataValue", ) } - this.rejectCircularInsertion(target, next, "Array assignment result", node) + rejectCircularInsertion(target, next, "Array assignment result", node) target[index] = next return } @@ -3374,7 +1941,7 @@ class Interpreter { } const target = reference.target as SafeObject const objectKey = key as string - this.rejectCircularInsertion(target, next, "Object assignment result", node) + rejectCircularInsertion(target, next, "Object assignment result", node) target[objectKey] = next } @@ -3385,283 +1952,4 @@ class Interpreter { throw new InterpreterRuntimeError("Property key must be a string or number.", node) } - - private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { - const scope = this.currentScope() - - // A pre-seeded parameter slot (initialized === false) is being bound for the first time; - // anything else already present is a genuine duplicate declaration. - const existing = scope.get(name) - if (existing && existing.initialized !== false) { - throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) - } - - scope.set(name, { mutable, value, initialized: true }) - } - - private getIdentifierValue(name: string, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") - } - - // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. - if (binding.initialized === false) { - throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") - } - - return binding.value - } - - private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") - } - - if (!binding.mutable) { - throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") - } - - binding.value = value - return value - } - - private resolveBinding(name: string): Binding | undefined { - for (let index = this.scopes.length - 1; index >= 0; index -= 1) { - const scope = this.scopes[index] - const binding = scope?.get(name) - - if (binding) { - return binding - } - } - - return undefined - } - - private currentScope(): Map { - const scope = this.scopes[this.scopes.length - 1] - - if (!scope) { - throw new InterpreterRuntimeError("Interpreter scope stack is empty.") - } - - return scope - } - - private pushScope(): void { - this.scopes.push(new Map()) - } - - private popScope(): void { - this.scopes.pop() - } -} - -/** - * Executes one Effect-native CodeMode program without constructing a reusable runtime. - * - * @example - * ```ts - * const result = yield* CodeMode.execute({ - * tools: { lookup }, - * code: `return await tools.lookup({ id: "order_42" })`, - * }) - * ``` - */ -export const executeWithLimits = >( - options: ExecuteOptions, - limits: ResolvedExecutionLimits, - searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], -): Effect.Effect> => { - if (options.code.trim().length === 0) { - return Effect.succeed({ - ok: false, - error: { kind: "ParseError", message: "Code cannot be empty." }, - toolCalls: [], - }) - } - - // Suspended so all per-execution state - tool-call admission budget and audit list, logs, - // and the timeout path's completed value - binds at run time: a reused Effect must start - // from a clean slate instead of observing a previous run's state. - return Effect.suspend(() => { - const hooks = { - ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), - ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), - } - const tools = ToolRuntime.make( - (options.tools ?? {}) as HostTools>, - limits.maxToolCalls, - searchIndex, - hooks, - ) - const logs: Array = [] - const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) - // Set once the program body returned and its value crossed the data boundary, so a timeout - // firing during leftover interruption reports "completed with interrupted background work" - // instead of discarding the computed value as a plain timeout. - let returned: { value: DataValue; promises: PromiseRuntime> } | undefined - - const base = Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - Effect.gen(function* () { - const program = parseProgram(options.code) - const promises = new PromiseRuntime>(scope) - const interpreter = new Interpreter>(tools.invoke, tools.keys, promises, logs) - const value = yield* interpreter.run(program) - // Validate the result first so an invalid value is a fatal completion that closes - // the promise scope directly instead of taking the normal-completion path. - const result = copyOut(copyIn(value, "Execution result"), true) as DataValue - returned = { value: result, promises } - const warnings = yield* promises.interrupt() - return { - ok: true, - value: result, - ...(warnings.length > 0 ? { warnings } : {}), - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }), - (scope, exit) => Scope.close(scope, exit), - ) - const timeoutMs = limits.timeoutMs - const operation = - timeoutMs === undefined - ? base - : base.pipe( - Effect.timeoutOrElse({ - duration: timeoutMs, - orElse: () => - Effect.sync(() => { - if (returned === undefined) { - return { - ok: false, - error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, - ...logged(), - toolCalls: tools.calls, - } satisfies Result - } - // The timeout warning leads so byte-budget truncation cuts it last. - return { - ok: true, - value: returned.value, - warnings: [ - { - kind: "TimeoutExceeded", - message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`, - }, - ...returned.promises.diagnostics(), - ], - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }), - }), - ) - - return operation.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt - : Effect.succeed({ - ok: false, - error: normalizeError(Cause.squash(cause)), - ...logged(), - toolCalls: tools.calls, - } satisfies Result), - ), - Effect.map((result) => - limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes), - ), - ) - }) -} - -const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength - -// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte -// sequence decodes to a replacement character, which is dropped). -const utf8Truncate = (value: string, maxBytes: number): string => { - const bytes = new TextEncoder().encode(value) - if (bytes.byteLength <= maxBytes) return value - const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) - return text.endsWith("\uFFFD") ? text.slice(0, -1) : text -} - -/** - * Bounds retained program payload bytes (serialized result value and logs) to `maxOutputBytes`. - * Warning diagnostics are bounded by a separate budget of the same size so a large value can - * never starve runtime-authored diagnostics. Fixed truncation notices are added outside those - * budgets, as is any framing added when a host renders the structured result. Truncation never - * fails the execution; `truncated: true` marks affected results. Only runs when the host set - * `maxOutputBytes` - with the limit absent, output passes through unbounded. - */ -const boundOutput = (result: Result, maxOutputBytes: number): Result => { - let truncated = false - - let value: DataValue = null - let valueBytes = 0 - if (result.ok) { - const serialized = JSON.stringify(result.value) ?? "null" - const bytes = utf8ByteLength(serialized) - if (bytes > maxOutputBytes) { - truncated = true - value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` - valueBytes = maxOutputBytes - } else { - value = result.value - valueBytes = bytes - } - } - - const warnings = result.ok ? (result.warnings ?? []) : [] - const keptWarnings: Array = [] - let warningBytes = 0 - for (const warning of warnings) { - const bytes = utf8ByteLength(JSON.stringify(warning)) + 1 - if (warningBytes + bytes > maxOutputBytes) break - warningBytes += bytes - keptWarnings.push(warning) - } - if (keptWarnings.length < warnings.length) { - truncated = true - keptWarnings.push({ - kind: "Truncated", - message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`, - }) - } - - const logs = result.logs ?? [] - const kept: Array = [] - const logBudget = Math.max(0, maxOutputBytes - valueBytes) - let logBytes = 0 - for (const line of logs) { - const lineBytes = utf8ByteLength(line) + 1 - if (logBytes + lineBytes > logBudget) break - logBytes += lineBytes - kept.push(line) - } - if (kept.length < logs.length) { - truncated = true - kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) - } - - if (!truncated) return result - const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {} - const logsPart = kept.length > 0 ? { logs: kept } : {} - return result.ok - ? { - ok: true, - value, - ...warningsPart, - ...logsPart, - truncated: true, - toolCalls: result.toolCalls, - } - : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } } diff --git a/packages/codemode/src/interpreter/scope.ts b/packages/codemode/src/interpreter/scope.ts new file mode 100644 index 0000000000..f5ee137cbc --- /dev/null +++ b/packages/codemode/src/interpreter/scope.ts @@ -0,0 +1,84 @@ +import { type AstNode, type Binding, InterpreterRuntimeError } from "./model.js" + +export class ScopeStack { + private readonly scopes: Array> + + constructor(scopes: Array>) { + this.scopes = scopes + } + + declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.current() + + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + get(name: string, node: AstNode): unknown { + const binding = this.resolve(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") + } + + return binding.value + } + + set(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolve(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") + } + + binding.value = value + return value + } + + resolve(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + current(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + push(scope: Map = new Map()): void { + this.scopes.push(scope) + } + + pop(): void { + this.scopes.pop() + } + + capture(): Array> { + return this.scopes.slice() + } +} diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 7f1770ef36..e1f2c64166 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -31,10 +31,8 @@ export type { } from "./types.js" /** - * Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per - * operation. Auth is resolved host-side via `auth.resolve` and never - * model-visible. Tools require `HttpClient.HttpClient`; unrepresentable - * operations land in `skipped`. + * Builds one CodeMode tool per representable OpenAPI 3.x operation. Auth remains host-side, + * tools require `HttpClient.HttpClient`, and unrepresentable operations land in `skipped`. */ export const fromSpec = (options: Options): Result => { const document = options.spec diff --git a/packages/codemode/src/openapi/runtime.ts b/packages/codemode/src/openapi/runtime.ts index 47312ae162..2b2dcd1e51 100644 --- a/packages/codemode/src/openapi/runtime.ts +++ b/packages/codemode/src/openapi/runtime.ts @@ -46,9 +46,7 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect>, ): Effect.Effect => Effect.gen(function* () { - // Validate every model-controlled value before auth resolution, which may refresh tokens. + // Validate model input before auth resolution can refresh credentials. const url = buildUrl(plan, input) if (url instanceof ToolError) return yield* Effect.fail(url) const missing = plan.fields.find( @@ -79,7 +77,6 @@ const buildRequest = ( request = serialized } - // Host headers first, then declared header parameters. request = HttpClientRequest.setHeaders(request, plan.headers) for (const field of plan.fields) { if (field.location !== "header") continue @@ -171,7 +168,7 @@ const applyCredentials = ( continue } if (credential.type === "basic") { - // Buffer instead of btoa: btoa throws on non-Latin-1 credentials. + // Basic auth credentials are UTF-8; btoa rejects non-Latin-1 input. const duplicate = add( "header", "authorization", @@ -185,7 +182,6 @@ const applyCredentials = ( if (duplicate !== undefined) return duplicate continue } - // apiKey: the carrier comes from the scheme declaration. if (definition.type !== "apiKey") { return toolError( `Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`, @@ -208,13 +204,13 @@ const buildUrl = (plan: Plan, input: Readonly>): string return toolError(`Missing required path parameter '${field.inputName}'.`) } const fieldValue = serializeSimple(field, item, (value) => - encodeURIComponent(value).replace(/[!'()*]/g, (character) => - `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, ), ) if (fieldValue instanceof ToolError) return fieldValue - // '.'/'..' survive encoding and URL normalization collapses them, letting a - // model-supplied value retarget the request to a different endpoint. + // URL normalization collapses encoded `.` and `..`, which could retarget the request. if (fieldValue === "" || fieldValue === "." || fieldValue === "..") { return toolError(`Invalid path parameter '${field.inputName}'.`) } @@ -271,10 +267,7 @@ const serializeQuery = ( if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) { return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`) } - return value.reduce( - (current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), - request, - ) + return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request) } if (isRecord(value) && field.explode) { return Object.entries(value).reduce((current, [name, item]) => { @@ -289,11 +282,15 @@ const serializeQuery = ( return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered) } -const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect => +const readResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + plan: Plan, +): Effect.Effect => Effect.gen(function* () { const contentLength = response.headers["content-length"] const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10) - const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) { return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`)) } @@ -304,7 +301,9 @@ const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`)) } if (size + chunk.byteLength > body.byteLength) { - const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2))) + const grown = Buffer.allocUnsafe( + Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)), + ) body.copy(grown, 0, 0, size) body = grown } diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 22cf1535a8..b74b3e69f2 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -23,8 +23,7 @@ const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value export const nonEmptyString = (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined -// Guards record lookups keyed by spec- or model-controlled names against -// prototype-inherited values (e.g. a parameter named `toString`). +// Spec- and model-controlled keys must not resolve inherited properties. export const own = (record: Readonly>, key: string): T | undefined => Object.hasOwn(record, key) ? record[key] : undefined @@ -78,7 +77,9 @@ const isBinaryMediaType = (document: Document, mediaType: string, value: unknown return isRecord(schema) && schema.format === "binary" } -const jsonContent = (content: Record): { readonly mediaType: string; readonly schema: unknown } | undefined => { +const jsonContent = ( + content: Record, +): { readonly mediaType: string; readonly schema: unknown } | undefined => { const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType)) return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined } @@ -104,7 +105,7 @@ const operationParameters = ( pathItem: Record, operation: Record, ): Parsed> => { - // Operation-level parameters override path-level ones sharing (location, name). + // OpenAPI operation parameters override path parameters with the same location and name. const declared = new Map< string, { readonly name: string; readonly location: string; readonly parameter: Record } @@ -344,7 +345,7 @@ export const operationOutput = ( if (outcomes.length === 0) return { ok: true, value: undefined } return { ok: true, - value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions), + value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions), } } @@ -380,7 +381,9 @@ export const operationPath = ( namespaces: ReadonlySet, ): ReadonlyArray => { const raw = nonEmptyString(operation.operationId) - const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment) + const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map( + sanitizeOperationSegment, + ) if (isOperationPathAvailable(segments, used, namespaces)) return segments const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join("."))) if (conflict >= 0 && conflict + 1 < segments.length) { diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts index cab772e701..6f3eb86283 100644 --- a/packages/codemode/src/openapi/types.ts +++ b/packages/codemode/src/openapi/types.ts @@ -22,9 +22,8 @@ export type SecurityScheme = | { readonly type: "openIdConnect" } /** - * Credential material returned by a host auth resolver. The carrier for `apiKey` - * comes from the scheme definition, not the credential. `header` is the escape - * hatch for nonstandard schemes. + * Credential material returned by a host auth resolver. `apiKey` uses the scheme's carrier; + * `header` supports nonstandard schemes. */ export type Credential = | { readonly type: "bearer"; readonly token: string } @@ -33,9 +32,7 @@ export type Credential = | { readonly type: "header"; readonly name: string; readonly value: string } /** - * Resolves credential material for one named security scheme at call time. - * `undefined` means unavailable, try the next OR alternative; a failure aborts - * the call rather than falling through. + * Resolves credentials at call time. `undefined` tries the next OR alternative; failure aborts. */ export type AuthResolver = (context: { readonly name: string @@ -74,9 +71,7 @@ export type Parsed = { readonly ok: true; readonly value: T } | { readonly ok export type InputLocation = "path" | "query" | "header" | "body" export type InputField = { - /** Model-visible field name after cross-location collision handling. */ readonly inputName: string - /** Original parameter or body-property name used on the wire. */ readonly name: string readonly location: InputLocation readonly required: boolean @@ -92,7 +87,6 @@ export type OperationInput = { readonly body: Body | undefined } -/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */ export type SecurityRequirement = Readonly>> export type Plan = { diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index 798563128e..4663f62f1a 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -1,4 +1,122 @@ +import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js" +import { copyIn, copyOut } from "../tool-runtime.js" +import { + isSandboxValue, + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" +import { boundedData, coerceToString } from "./value.js" + export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) -/** Console formatting recursion ceiling; deeper values render as "...". */ -export const MAX_CONSOLE_DEPTH = 32 +const MAX_CONSOLE_DEPTH = 32 + +export const formatConsoleMessage = (name: string, args: Array): string => { + if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0]) + if (name === "table") return formatConsoleTable(args[0], args[1]) + const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" + return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}` +} + +const formatConsoleArgument = (value: unknown): string => { + if (value === undefined) return "undefined" + if (typeof value === "string") return value + return formatConsoleValue(value, new Set(), 0) +} + +const formatConsoleValue = (value: unknown, seen: Set, depth: number): string => { + if (value === null || value === undefined) return "null" + if (typeof value === "string") return JSON.stringify(value) + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value !== "object") return String(value) + if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" + if (value instanceof SandboxDate) return coerceToString(value) + if (value instanceof SandboxRegExp) return coerceToString(value) + if (value instanceof SandboxURL) return coerceToString(value) + if (value instanceof SandboxURLSearchParams) return coerceToString(value) + if (depth > MAX_CONSOLE_DEPTH) return "..." + if (seen.has(value)) return "[Circular]" + if (value instanceof SandboxMap) { + seen.add(value) + try { + const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) + return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (value instanceof SandboxSet) { + seen.add(value) + try { + return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (isRuntimeReference(value)) return "[CodeMode reference]" + seen.add(value) + try { + if (Array.isArray(value)) { + return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]` + } + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`) + .join(",")}}` + } finally { + seen.delete(value) + } +} + +const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => { + if (value === undefined) return "undefined" + if (containsOpaqueReference(value)) return "[CodeMode reference]" + const data = boundedData(value, "console.table argument") + const columns = consoleTableColumns(columnsArgument) + const rows = consoleTableRows(data, columns) + const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) + const header = ["(index)", ...keys].join("\t") + return [ + header, + ...rows.map((row) => [row.index, ...keys.map((key) => formatConsoleTableCell(row.values[key]))].join("\t")), + ].join("\n") +} + +const consoleTableColumns = (value: unknown): ReadonlyArray | undefined => { + if (value === undefined) return undefined + if (containsRuntimeReference(value)) return undefined + const columns = copyOut(copyIn(value, "console.table columns"), true) + return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined +} + +const consoleTableRows = ( + data: unknown, + columns: ReadonlyArray | undefined, +): Array<{ readonly index: string; readonly values: Record }> => { + if (Array.isArray(data)) { + return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) })) + } + if (data !== null && typeof data === "object" && !isSandboxValue(data)) { + return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) })) + } + return [{ index: "0", values: { Value: data } }] +} + +const consoleTableValues = (value: unknown, columns: ReadonlyArray | undefined): Record => { + if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { + const source = value as Record + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) + return Object.fromEntries(Object.entries(source)) + } + return { Value: value } +} + +const formatConsoleTableCell = (value: unknown): string => { + if (value === undefined) return "" + if (typeof value === "string") return value + return formatConsoleValue(value, new Set(), 0) +} diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index c492f58f94..11566e2d7c 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/packages/codemode/src/stdlib/date.ts @@ -23,8 +23,6 @@ export const dateMethods = new Set([ "getTimezoneOffset", ]) -export const dateStatics = new Set(["now", "parse", "UTC"]) - export const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { switch (name) { case "now": diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 8a479d2c8c..a7cc13629e 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -6,10 +6,7 @@ import { } from "../interpreter/model.js" import { copyIn, copyOut } from "../tool-runtime.js" -export const jsonStatics = new Set(["stringify", "parse"]) - export const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { - if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) switch (name) { case "stringify": { const replacer = args[1] diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 49a61110dc..f25077857f 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -3,11 +3,9 @@ import { isBlockedMember } from "../tool-runtime.js" import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js" import { boundedData, coerceToString } from "./value.js" -export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { - if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) const requireObject = (): Record => { const input = args[0] if (Array.isArray(input)) return input as unknown as Record @@ -53,13 +51,11 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast } const out = target as Record for (const source of args.slice(1)) { - if (source === null || source === undefined) continue - const value = source - if (isSandboxValue(value)) continue - if (value === null || typeof value !== "object" || Array.isArray(value)) { + if (source === null || source === undefined || isSandboxValue(source)) continue + if (typeof source !== "object" || Array.isArray(source)) { throw new InterpreterRuntimeError("Object.assign expects data objects.", node) } - for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + for (const [key, item] of Object.entries(source)) guardedSet(out, key, item) } return out } diff --git a/packages/codemode/src/stdlib/promise.ts b/packages/codemode/src/stdlib/promise.ts index d0b442ccf7..2bad2caf0c 100644 --- a/packages/codemode/src/stdlib/promise.ts +++ b/packages/codemode/src/stdlib/promise.ts @@ -1,6 +1,5 @@ import type { PromiseMethodName } from "../interpreter/model.js" -export const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) +export const promiseStatics = new Set(["all", "allSettled", "race", "any", "resolve", "reject"]) -/** Maximum number of eagerly forked tool calls that may run concurrently. */ export const TOOL_CALL_CONCURRENCY = 8 diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index 3ac4372e36..ffc33797dd 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -4,12 +4,9 @@ export const stringMethods = new Set([ "trim", "trimStart", "trimEnd", - "trimLeft", - "trimRight", "split", "slice", "substring", - "substr", "includes", "startsWith", "endsWith", diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 17ca8b1c48..7cdd8cc7dc 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -6,6 +6,7 @@ export const errorConstructors = new Set([ "ReferenceError", "EvalError", "URIError", + "AggregateError", ]) export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"]) @@ -20,6 +21,9 @@ export const createErrorValue = (name: string, message: string): SafeObject => { return value } +export const createAggregateErrorValue = (errors: Array, message: string): SafeObject => + Object.assign(createErrorValue("AggregateError", message), { errors }) + export const errorBrandName = (value: unknown): string | undefined => value !== null && typeof value === "object" ? ((value as Record)[ErrorBrand] as string | undefined) @@ -60,7 +64,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseInt") return parseInt(coerceToString(raw)) return parseFloat(coerceToString(raw)) } - const value = boundedData(args[0], `${ref.name} input`) + const value = boundedData(raw, `${ref.name} input`) if (ref.name === "Number") return coerceToNumber(value) if (ref.name === "Boolean") return Boolean(value) if (ref.name === "parseInt") { @@ -73,11 +77,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) return coerceToString(value) } -import { - type AstNode, - CoercionFunction, - InterpreterRuntimeError, -} from "../interpreter/model.js" +import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js" import { copyIn, type SafeObject } from "../tool-runtime.js" import { isSandboxValue, diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 3cdf49cfe5..0bb8c00aec 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -44,36 +44,30 @@ type ServicesOf> = Depth["length"] e : ServicesOf : never -/** Minimal audit record retained for each admitted tool call. */ export type ToolCall = { readonly name: string } -/** Decoded tool call observed immediately before tool execution. */ export type ToolCallStarted = { readonly index: number readonly name: string readonly input: unknown } -/** Completed tool call observed immediately after tool execution settles. */ export type ToolCallEnded = { readonly index: number readonly name: string readonly input: unknown readonly durationMs: number readonly outcome: "success" | "failure" - /** Model-safe failure message; present only when `outcome` is `"failure"`. */ readonly message?: string } -/** Non-throwing observation hooks fired around each admitted tool call. */ export type ToolCallHooks = { readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined } -/** Model-visible description of one schema-backed tool. */ export type ToolDescription = { readonly path: string readonly description: string @@ -82,7 +76,6 @@ export type ToolDescription = { export type SafeObject = Record -const reservedNamespace = "$codemode" const defaultCatalogBudget = 2_000 const defaultSearchLimit = 10 const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) @@ -114,11 +107,6 @@ export class ToolReference { constructor(readonly path: ReadonlyArray) {} } -/** - * Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable - * limit) purely because it produces a clearer diagnostic than a native stack-overflow - * RangeError would. - */ const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { @@ -153,21 +141,7 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) -/** - * Validates and copies a value against the plain-data contract (depth, circularity, plain - * objects only, blocked properties, data-only leaves). - * - * Two modes share the walk: - * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - - * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize - * exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}. - * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in - * codemode.ts): standard-library value instances pass through untouched (treated as leaves, - * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and - * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). - * - * Both modes reject un-awaited promises with an await-hinting diagnostic. - */ +// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them. export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => copyBounded(value, label, 0, new Set(), preserveSandboxValues) @@ -186,10 +160,6 @@ const copyBounded = ( value === undefined || typeof value === "string" || typeof value === "boolean" || - // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real - // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are - // normalized to `null` when the value leaves the sandbox - see copyOut - exactly as - // JSON.stringify already does at any tool boundary. typeof value === "number" ) { return value @@ -199,8 +169,6 @@ const copyBounded = ( throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) } - // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the - // model exactly how to fix the program instead. if (value instanceof SandboxPromise) { throw new ToolRuntimeError( "InvalidDataValue", @@ -209,9 +177,6 @@ const copyBounded = ( } if (preserveSandboxValues) { - // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents - // are never walked here (Map/Set members are validated where mutation happens, and the - // real boundary still serializes them below). if ( value instanceof SandboxDate || value instanceof SandboxRegExp || @@ -222,8 +187,6 @@ const copyBounded = ( ) { return value } - // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross - // the boundary first), but wrap them defensively rather than degrading to JSON forms. if (value instanceof Date) return new SandboxDate(value.getTime()) if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) if (value instanceof Map) { @@ -242,9 +205,6 @@ const copyBounded = ( if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value)) } - // Sandbox value types (and their host counterparts, which a host tool may legitimately - // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use - // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}. if (value instanceof SandboxDate) { return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null } @@ -275,7 +235,7 @@ const copyBounded = ( if (Array.isArray(value)) { const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) if (preserveSandboxValues) { - // Array metadata is not serialized, but intra-sandbox copies must retain it. + // Checkpoint copies retain array metadata that boundary copies omit. for (const [key, item] of Object.entries(value)) { if (Object.hasOwn(copied, key)) continue if (isBlockedMember(key)) { @@ -306,9 +266,6 @@ const copyBounded = ( export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { if (value === undefined && undefinedAsNull) return null - // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return - // and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity - // have no JSON representation, so JSON.stringify would produce null anyway. if (typeof value === "number" && !Number.isFinite(value)) { return null } @@ -326,15 +283,12 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { const definitions = ( tools: HostTools, path: ReadonlyArray = [], -): Array<{ path: string; definition: Definition }> => { - const entries: Array<{ path: string; definition: Definition }> = [] - for (const [name, value] of Object.entries(tools)) { +): Array<{ path: string; definition: Definition }> => + Object.entries(tools).flatMap(([name, value]) => { const next = [...path, name] - if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) - else if (typeof value !== "function") entries.push(...definitions(value, next)) - } - return entries -} + if (isDefinition(value)) return [{ path: next.join("."), definition: value }] + return typeof value === "function" ? [] : definitions(value, next) + }) const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ path, @@ -349,9 +303,6 @@ const visibleDefinitions = (tools: HostTools) => description: describeDefinition(path, definition), })) -export const catalog = (tools: HostTools): ReadonlyArray => - visibleDefinitions(tools).map(({ description }) => description) - export type DiscoveryPlan = { readonly catalog: ReadonlyArray readonly instructions: string @@ -360,18 +311,10 @@ export type DiscoveryPlan = { export type SearchEntry = { readonly description: ToolDescription - /** Top-level namespace (first path segment), matched by the search `namespace` option. */ readonly namespace: string - /** Lowercased path + description + input property names/descriptions, for substring matching. */ readonly searchText: string } -/** - * Split a query into lowercased search terms. camelCase boundaries are split - * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a - * separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all - * tokenize alike. Empties and the `*` wildcard are dropped. - */ const tokenize = (query: string): Array => query .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -379,13 +322,6 @@ const tokenize = (query: string): Array => .split(/[^a-z0-9]+/) .filter((term) => term.length > 0 && term !== "*") -/** - * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural - * query term ("issues") still matches indexed text that only carries the singular - * ("issue"). Matching is one-directional substring containment, so the variants are - * needed only on the query side; scoring weights are unchanged - each field check - * passes when ANY form matches. - */ const termForms = (term: string): Array => { const forms = [term] if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2)) @@ -407,8 +343,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => request.namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === request.namespace) - // A query that names one tool path exactly (canonical path or rendered JavaScript - // expression) is a lookup, not a search: return that tool alone. const trimmed = query.trim() const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed const exact = @@ -418,9 +352,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, ) const terms = tokenize(query).map(termForms) - // Additive field-weighted scoring, summed across terms: exact path or path segment - // (20) > path substring (8) > description substring (4) > any searchable text, - // including input parameter names and descriptions (2). const ranked = exact !== undefined ? [exact] @@ -458,10 +389,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => }), }) -const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([])) +const searchSignature = (() => { + const definition = makeSearchTool([]) + return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}` +})() const catalogLine = (tool: ToolDescription) => { - // Keep the tool description concise; the full schema documentation remains in the signature. const line = tool.description.split("\n", 1)[0]!.trim() const description = line.length > 120 ? line.slice(0, 119) + "..." : line return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` @@ -481,27 +414,10 @@ const toSearchEntry = (path: string, definition: Definition, description: .toLowerCase(), }) -/** The runtime search index over every described tool. Search is always registered. */ export const searchIndex = (tools: HostTools): ReadonlyArray => visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) -export const assertValidTools = (tools: HostTools): void => { - if (Object.hasOwn(tools, reservedNamespace)) { - throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`) - } -} - -/** - * Budgeted catalog: every namespace is always listed with its tool count; full call - * signatures are inlined against the `catalogBudget` (estimated tokens, - * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every - * namespace still holding un-inlined tools attempts to place its next-cheapest line, and - * a namespace whose next line does not fit is done while the others keep going - so every - * namespace gets some representation before any namespace gets everything. The section - * states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per - * namespace. Namespace stub lines are never budgeted: every namespace appears with its - * tool count even at budget 0. - */ +// Budget signatures round-robin so every namespace remains visible. export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") @@ -518,12 +434,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) - // Select which signatures fit the budget before emitting, so the list can state - // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces - // alphabetical), every namespace still holding un-inlined tools tries to place its - // next-cheapest line against the shared budget; a namespace whose next line does not - // fit is done - the others keep going - so every namespace gets some representation - // before any namespace gets everything. const selections = ordered.map(([namespace, group]) => ({ namespace, picked: new Set(), @@ -555,23 +465,17 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu const empty = described.length === 0 - // Section order is deliberate: workflow first (the top is the least likely part of a long - // description to be truncated or skimmed away), then rules, then syntax, with the budgeted - // catalog at the bottom. Example call forms use placeholders - never a real or fabricated - // tool name - and show both dot and bracket notation so non-identifier names are not normalized. const intro = [ empty ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." : complete - ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available." - : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.", + ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available." + : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.", ...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), ] - // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE - // catalog already shows every signature, so step 1 picks from the list instead. const workflow = empty ? [] : [ @@ -585,7 +489,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.", ] : [ - '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', + '1. If needed, discover tools with the built-in search function: `return search({ query: "" })`.', "2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.", ]), ] @@ -597,8 +501,8 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "## Rules", "", complete - ? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed." - : "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.", + ? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed." + : "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.", "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", "- A result typed `Promise` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.", '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', @@ -607,7 +511,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu ...(complete ? [] : [ - '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + '- Browse one namespace: `search({ query: "", namespace: "" })`.', "- If search returns `next`, repeat the same search with `offset: next.offset`.", ]), ] @@ -617,7 +521,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "## Language", "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] @@ -629,14 +533,12 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu toolSection.push( complete ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" - : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`, + : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`, "", ) for (const [namespace, group] of ordered) { const picked = shown.get(namespace)! const count = `${group.length} tool${group.length === 1 ? "" : "s"}` - // Annotate only when a namespace is not fully shown, so a comprehensive - // namespace reads cleanly and a truncated one is unambiguous. const label = picked.size === group.length ? count @@ -647,7 +549,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) } if (!complete) { - toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`) + toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) } } @@ -659,13 +561,6 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } } -/** - * The enumerable names at one node of the callable tool tree - namespace names at the root, - * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool - * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a - * function in JS). An unknown path is an `UnknownTool` error pointing at the working - * discovery idioms, mirroring how calling an unknown tool fails. - */ const namespaceKeys = (tools: HostTools, path: ReadonlyArray): ReadonlyArray => { let value: HostTool | Definition | HostTools = tools for (const segment of path) { @@ -676,7 +571,7 @@ const namespaceKeys = (tools: HostTools, path: ReadonlyArray): Rea !Object.hasOwn(value, segment) ) { throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [ - "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", + "Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.", ]) } value = value[segment] as HostTool | Definition | HostTools @@ -696,7 +591,7 @@ const resolve = (tools: HostTools, path: ReadonlyArray): HostTool< !Object.hasOwn(value, segment) ) { throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [ - "Use tools.$codemode.search({ query }) to find available described tools.", + "Use search({ query }) to find available described tools.", ]) } value = value[segment] as HostTool | Definition | HostTools @@ -713,25 +608,20 @@ export type ToolRuntime = { readonly root: ToolReference readonly calls: Array readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect - /** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */ + readonly search: (args: Array) => Effect.Effect readonly keys: (path: ReadonlyArray) => ReadonlyArray } export const make = ( tools: HostTools, - /** Undefined means unlimited tool calls. */ maxToolCalls: number | undefined, searchIndex: ReadonlyArray, hooks?: ToolCallHooks, ): ToolRuntime => { const calls: Array = [] - const callableTools = { - ...tools, - [reservedNamespace]: { search: makeSearchTool(searchIndex) }, - } + const searchTool = makeSearchTool(searchIndex) - // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure - // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome. + // End hooks observe settled success or failure; interruption emits neither outcome. const observeEnd = (effect: Effect.Effect, call: ToolCallStarted): Effect.Effect => { const onEnd = hooks?.onToolCallEnd if (onEnd === undefined) return effect @@ -764,52 +654,59 @@ export const make = ( calls.push(call) } + const recordAndObserve = (name: string, input: unknown) => + Effect.sync(() => { + recordCall({ name }) + return calls.length - 1 + }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) + + const invokeDefinition = (name: string, tool: Definition, externalArgs: Array) => + Effect.gen(function* () { + if (externalArgs.length !== 1) + throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) + const input = yield* Effect.try({ + try: () => decodeToolInput(tool, externalArgs[0]), + catch: (cause) => + new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + }) + const index = yield* recordAndObserve(name, input) + return yield* observeEnd( + Effect.gen(function* () { + const raw = yield* runHost(Effect.suspend(() => tool.run(input))) + const result = yield* Effect.try({ + try: () => decodeToolOutput(tool, raw), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + return yield* decodeOutput(result, name) + }), + { index, name, input }, + ) + }) + return { root: new ToolReference([]), calls, - keys: (path) => namespaceKeys(callableTools, path), + keys: (path) => namespaceKeys(tools, path), + search: (args) => + Effect.suspend(() => + invokeDefinition( + "search", + searchTool, + args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))), + ), + ), invoke: (path, args) => Effect.gen(function* () { const name = path.join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) - const call = { name } - const recordAndObserve = (input: unknown) => - Effect.sync(() => { - recordCall(call) - return calls.length - 1 - }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) - const tool = resolve(callableTools, path) - let describedInput: unknown - if (isDefinition(tool)) { - if (externalArgs.length !== 1) - throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) - describedInput = yield* Effect.try({ - try: () => decodeToolInput(tool, externalArgs[0]), - catch: (cause) => - new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), - }) - } - const input = isDefinition(tool) ? describedInput : externalArgs - const index = yield* recordAndObserve(input) - const currentCall = { index, name, input } - if (isDefinition(tool)) { - return yield* observeEnd( - Effect.gen(function* () { - const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput))) - const result = yield* Effect.try({ - try: () => decodeToolOutput(tool, raw), - catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), - }) - return yield* decodeOutput(result, name) - }), - currentCall, - ) - } + const tool = resolve(tools, path) + if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs) + const index = yield* recordAndObserve(name, externalArgs) return yield* observeEnd( Effect.gen(function* () { return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) }), - currentCall, + { index, name, input: externalArgs }, ) }), } diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 16213fa8ee..d8b48dc548 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -5,13 +5,8 @@ const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" -/** - * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, - * with dot access as a tool-path segment). Anything else must be quoted/bracketed. - */ export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ -/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) const effectNumberSentinel = (schema: JsonSchema) => @@ -23,20 +18,14 @@ const effectNumberSentinel = (schema: JsonSchema) => const intersection = (members: ReadonlyArray): string => { const concrete = members.filter((member) => member !== "unknown") if (concrete.length === 0) return "unknown" - if (concrete.length === 1) return concrete[0] ?? "unknown" + if (concrete.length === 1) return concrete[0] return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") } -/** - * Recursion ceiling for schema rendering. Object, array, and union recursion all increment - * depth, so this bounds every recursion path - pathological or structurally cyclic schemas - * degrade to `unknown` instead of overflowing the stack (rendering must never throw). - */ const MAX_RENDER_DEPTH = 8 type RenderContext = { readonly definitions: Readonly> - /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ readonly pretty: boolean } @@ -64,10 +53,6 @@ const hasUnresolvedRef = ( ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) } -/** - * Schema constraints a TypeScript type cannot express natively but a model benefits from, - * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). - */ const docTags = (schema: JsonSchema): Array => { const tags: Array = [] if (schema.deprecated === true) tags.push("@deprecated") @@ -75,9 +60,7 @@ const docTags = (schema: JsonSchema): Array => { try { const rendered = JSON.stringify(schema.default) if (rendered !== undefined) tags.push(`@default ${rendered}`) - } catch { - // unserializable default: skip rather than emit a broken tag - } + } catch {} } if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) @@ -85,13 +68,7 @@ const docTags = (schema: JsonSchema): Array => { return tags } -/** - * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, - * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a - * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and - * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so - * callers can prepend it directly to the field line. - */ +// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation. const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => line.replaceAll("*/", "* /").replace(/\s+$/, ""), @@ -128,17 +105,11 @@ const renderSchema = ( if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") const alternatives = schema.anyOf ?? schema.oneOf if (alternatives) { - // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, - // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; - // real JSON Schema unions such as `string | number` or `number | null` must keep - // every branch. if ( alternatives.some((item) => item.type === "number") && alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) ) return "number" - // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` - // (no properties/items); render the bare shape as {} instead of `{} | Array`. if ( alternatives.length === 2 && alternatives[0]?.type === "object" && @@ -183,7 +154,6 @@ const renderSchema = ( return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` } - // Pretty: an indented block, each described field preceded by its JSDoc comment. if (properties.length === 0 && indexType === undefined) return "{}" const pad = " ".repeat(depth + 1) const lines = properties.map( @@ -208,7 +178,6 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false } } -/** Renders a raw JSON Schema document as a TypeScript type string. */ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { try { return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) @@ -217,20 +186,12 @@ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): stri } } -/** One input property of a tool, extracted best-effort from its input schema. */ export type InputProperty = { readonly name: string readonly description: string | undefined readonly required: boolean } -/** - * The property names, descriptions, and required flags of a tool's input schema - the raw - * material for search text. Best-effort: Effect Schemas go through their - * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read - * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. - * Anything unresolvable yields `[]` (search falls back to path + description). - */ export const inputProperties = (definition: Definition): Array => { try { const document = isEffectSchema(definition.input) @@ -262,20 +223,11 @@ export const inputProperties = (definition: Definition): Array(definition: Definition, pretty = false): string => isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty) -/** - * The model-visible TypeScript type of a tool's result; tools without an output schema - * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. - */ export const outputTypeScript = (definition: Definition, pretty = false): string => definition.output === undefined ? "unknown" @@ -283,18 +235,9 @@ export const outputTypeScript = (definition: Definition, pretty = false): ? toTypeScript(definition.output, true, pretty) : jsonSchemaToTypeScript(definition.output, pretty) -/** - * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); - * JSON-Schema-described inputs pass through unvalidated (render-only). - */ export const decodeInput = (definition: Definition, value: unknown): unknown => isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value -/** - * Decodes a tool result before it is exposed to the program. Effect Schemas validate and - * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass - * the host value through unchanged. - */ export const decodeOutput = (definition: Definition, value: unknown): unknown => definition.output !== undefined && isEffectSchema(definition.output) ? Schema.decodeUnknownSync(definition.output)(value) diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 6c6863f99d..0535cc9caa 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -1,11 +1,8 @@ import { Effect, Schema } from "effect" /** - * JSON Schema subset accepted for render-only tool schemas. - * - * A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript - * signature only - CodeMode performs no validation against it. This is the natural shape for - * adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents. + * JSON Schema subset for model-visible signatures. CodeMode does not validate values against + * these schemas. */ export type JsonSchema = { readonly type?: string | ReadonlyArray @@ -41,10 +38,8 @@ export type Definition = { readonly run: (input: unknown) => Effect.Effect } -/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ type InputType = S extends Schema.Decoder ? S["Type"] : unknown -/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown /** Options for defining one CodeMode tool. */ @@ -61,29 +56,9 @@ export const isDefinition = (value: unknown): value is Definition /** * Defines one schema-described tool available to a CodeMode program through `tools.*`. * - * `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema - * document. Effect Schema input is decoded before `run` is invoked, and `run` returns the - * encoded representation of an Effect Schema `output`, which CodeMode decodes before returning - * it to the program. JSON Schemas only shape the model-visible signature; values pass through - * unvalidated. `output` is optional - without it the signature advertises `unknown` and the - * host result is exposed as-is. The host tool remains responsible for authorization and - * durable side-effect handling. - * - * @example - * ```ts - * const lookup = Tool.make({ - * description: "Look up an order", - * input: Schema.Struct({ id: Schema.String }), - * output: Schema.Struct({ status: Schema.String }), - * run: ({ id }) => Effect.succeed({ status: "open" }), - * }) - * - * const fromJsonSchema = Tool.make({ - * description: "Call an adapter-described tool", - * input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, - * run: (input) => callHost(input), - * }) - * ``` + * Effect Schemas validate values; JSON Schemas only shape the model-visible signature. + * Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization + * and durable side effects. */ export const make = ( options: Options, diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 2e09e9d3c9..a9218f0e7e 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -26,6 +26,22 @@ describe("CodeMode host failure boundary", () => { }) }) + test("does not rewrite explicit safe tool failures", async () => { + const result = await run( + Tool.make({ + description: "Fail safely", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("File not found: /tmp/report.json")), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "File not found: /tmp/report.json", + }) + }) + test("sanitizes unknown host failures and defects", async () => { for (const failure of [ Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })), @@ -541,11 +557,11 @@ describe("CodeMode public contract", () => { " - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID", ) // A fully inlined catalog does not advertise search in the instructions... - expect(runtime.instructions()).not.toMatch(/\$codemode/) + expect(runtime.instructions()).not.toContain("search(") - // ...but the search tool stays registered, so a speculative call still works with the + // ...but the search built-in stays available, so a speculative call still works with the // same signature as the inline catalog. - const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`)) + const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`)) expect(result.ok).toBe(true) if (result.ok) { expect(result.value).toStrictEqual({ @@ -583,9 +599,7 @@ describe("CodeMode public contract", () => { 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', ) - const search = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`), - ) + const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`)) expect(search.ok).toBe(true) if (search.ok) { expect(search.value).toStrictEqual({ @@ -608,7 +622,7 @@ describe("CodeMode public contract", () => { if (call.ok) expect(call.value).toBe("/resolved/TypeScript") const exact = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`), + runtime.execute(`return search({ query: 'tools.context7["resolve-library-id"]' })`), ) expect(exact.ok).toBe(true) if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null }) @@ -632,7 +646,7 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("Do not infer or normalize tool names") expect(instructions).toContain("bracket notation and quotes are part of the path") expect(instructions).toContain("surrounding agent tools are not available") - expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools") + expect(instructions).toContain("Only Code Mode tools listed here are available") // Placeholders use generic namespace/tool/field names only - no fabricated real tools // and no real catalog tools cherry-picked into example lines. expect(instructions).toContain("`const result = await tools..(input)`") @@ -651,15 +665,11 @@ describe("CodeMode public contract", () => { // PARTIAL: the workflow starts with search (with query-style guidance that is clearly // a query string, never a tool name) and the browse-namespace rule appears. expect(partial).toContain( - '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', + '1. If needed, discover tools with the built-in search function: `return search({ query: "" })`.', ) expect(partial).toContain("In the next execution, copy a returned path exactly") - expect(partial).toContain( - "Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools", - ) - expect(partial).toContain( - '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', - ) + expect(partial).toContain("Only Code Mode tools listed here or returned by the built-in `search` function") + expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "" })`.') expect(partial).toContain("repeat the same search with `offset: next.offset`") expect(partial).toContain(" limit?: number,\n offset?: number,") expect(partial).not.toContain("total_count") @@ -672,9 +682,11 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("not a general-purpose runtime") expect(instructions).not.toContain("Standard modern JavaScript works") expect(instructions).not.toContain("TypeScript type annotations") - for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) { + for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) { expect(instructions).toContain(missing) } + expect(instructions).not.toContain("new Promise(...) are unavailable") + expect(instructions).not.toContain("promise chaining") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).not.toContain("host globals") expect(instructions).toContain("Use Code Mode tools for external operations") @@ -694,7 +706,7 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("## Available tools") expect(instructions).not.toContain("## Workflow") expect(instructions).not.toContain("## Rules") - expect(instructions).not.toMatch(/\$codemode/) + expect(instructions).not.toContain("search(") }) test("uses one ranked search returning complete definitions for large catalogs", async () => { @@ -714,17 +726,15 @@ describe("CodeMode public contract", () => { tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, discovery: { catalogBudget: 0 }, }) - expect(runtime.instructions()).toContain( - "Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)", - ) + expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))") expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") - expect(runtime.instructions()).toMatch(/\$codemode\.search/) + expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {") expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) const result = await Effect.runPromise( runtime.execute(` - return await tools.$codemode.search({ + return search({ query: "send message attachment upload file to current Discord thread", limit: 2 }) @@ -748,14 +758,14 @@ describe("CodeMode public contract", () => { remaining: 0, next: null, }) - expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) + expect(result.toolCalls).toStrictEqual([{ name: "search" }]) const variants = await Effect.runPromise( runtime.execute(` - return await Promise.all([ - tools.$codemode.search({ query: "file" }), - tools.$codemode.search({ query: "image" }) - ]) + return [ + search({ query: "file" }), + search({ query: "image" }) + ] `), ) expect(variants.ok).toBe(true) @@ -767,12 +777,35 @@ describe("CodeMode public contract", () => { "tools.thread.generateImage", ) } + }) - const removed = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`), - ) - expect(removed.ok).toBe(false) - if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool") + test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => { + const started: Array = [] + const ended: Array = [] + const limited = CodeMode.make({ + tools, + limits: { maxToolCalls: 1 }, + onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)), + onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)), + }) + const result = await Effect.runPromise(limited.execute(`search({}); return search({})`)) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded") + expect(started).toEqual(["search"]) + expect(ended).toEqual(["search:success"]) + }) + + test("search is an opaque, shadowable global like other built-ins", async () => { + const runtime = CodeMode.make({ tools }) + expect(await Effect.runPromise(runtime.execute(`return typeof search`))).toMatchObject({ value: "function" }) + // A program-level declaration shadows the global, as JS module scope does. + const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`)) + expect(shadowed.ok).toBe(true) + if (shadowed.ok) expect(shadowed.value).toBe("local") + // The reference itself cannot cross the data boundary. + const escaped = await Effect.runPromise(runtime.execute(`return { search }`)) + expect(escaped.ok).toBe(false) + if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue") }) test("search defaults to 10 results and resolves exact tool paths", async () => { @@ -789,7 +822,7 @@ describe("CodeMode public contract", () => { }, }) - const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + const browse = await Effect.runPromise(runtime.execute(`return search({})`)) expect(browse.ok).toBe(true) if (browse.ok) { const value = browse.value as { @@ -803,9 +836,7 @@ describe("CodeMode public contract", () => { } for (const query of ["many.tool13", "tools.many.tool13"]) { - const exact = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), - ) + const exact = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`)) expect(exact.ok).toBe(true) if (exact.ok) { expect(exact.value).toStrictEqual({ @@ -839,9 +870,7 @@ describe("CodeMode public contract", () => { }) // Empty query + namespace browses just that namespace, alphabetical by path. - const browse = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`), - ) + const browse = await Effect.runPromise(runtime.execute(`return search({ query: "", namespace: "github" })`)) expect(browse.ok).toBe(true) if (browse.ok) { const value = browse.value as { items: Array<{ path: string }>; remaining: number } @@ -853,9 +882,7 @@ describe("CodeMode public contract", () => { } // A query + namespace ranks within that namespace only. - const scoped = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`), - ) + const scoped = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "linear" })`)) expect(scoped.ok).toBe(true) if (scoped.ok) { const value = scoped.value as { items: Array<{ path: string }>; remaining: number } @@ -863,9 +890,7 @@ describe("CodeMode public contract", () => { expect(value.items[0]?.path).toBe("tools.linear.list_issues") } - const invalid = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`), - ) + const invalid = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: 7 })`)) expect(invalid.ok).toBe(false) if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput") }) @@ -890,9 +915,7 @@ describe("CodeMode public contract", () => { // "attachment" appears in neither path nor description - only in the input schema's // property names, which the searchable text includes. - const byParameter = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`), - ) + const byParameter = await Effect.runPromise(runtime.execute(`return search({ query: "attachment" })`)) expect(byParameter.ok).toBe(true) if (byParameter.ok) { const value = byParameter.value as { items: Array<{ path: string }>; remaining: number } @@ -901,9 +924,7 @@ describe("CodeMode public contract", () => { } // Substring matching: a partial word ("docum") still hits the description. - const bySubstring = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "docum" })`), - ) + const bySubstring = await Effect.runPromise(runtime.execute(`return search({ query: "docum" })`)) expect(bySubstring.ok).toBe(true) if (bySubstring.ok) { const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number } @@ -930,9 +951,7 @@ describe("CodeMode public contract", () => { }) // "issues" still finds the singular-only tool (term OR singular(term) per field)... - const plural = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`), - ) + const plural = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "tracker" })`)) expect(plural.ok).toBe(true) if (plural.ok) { const value = plural.value as { items: Array<{ path: string }>; remaining: number } @@ -941,7 +960,7 @@ describe("CodeMode public contract", () => { } // ...while a true "issues" path match still outranks the singular-only description match. - const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`)) + const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "issues" })`)) expect(ranked.ok).toBe(true) if (ranked.ok) { const value = ranked.value as { items: Array<{ path: string }>; remaining: number } @@ -968,7 +987,7 @@ describe("CodeMode public contract", () => { alpha: { beta: simple("Middle"), aardvark: simple("First") }, }, }) - const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + const browse = await Effect.runPromise(runtime.execute(`return search({})`)) expect(browse.ok).toBe(true) if (browse.ok) { const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown } @@ -981,9 +1000,7 @@ describe("CodeMode public contract", () => { expect(value.next).toBeNull() } - const middle = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`), - ) + const middle = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 1 })`)) expect(middle.ok).toBe(true) if (middle.ok) { expect(middle.value).toMatchObject({ @@ -993,9 +1010,7 @@ describe("CodeMode public contract", () => { }) } - const exhausted = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`), - ) + const exhausted = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 3 })`)) expect(exhausted.ok).toBe(true) if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null }) }) @@ -1026,16 +1041,14 @@ describe("CodeMode public contract", () => { }) const instructions = runtime.instructions() - expect(instructions).toContain( - "Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)", - ) + expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))") expect(instructions).toContain("- alpha (2 tools, 1 shown)") expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise // Cheap") expect(instructions).not.toContain("tools.alpha.expensive(") // Fully shown namespaces read cleanly (no "shown" annotation). expect(instructions).toContain("- beta (1 tool)") expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise // Cheap") - expect(instructions).toMatch(/\$codemode\.search/) + expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {") }) test("charges inline JSDoc against the catalog token budget", () => { @@ -1056,9 +1069,7 @@ describe("CodeMode public contract", () => { }) expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.") - expect(runtime.instructions()).toContain( - "Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)", - ) + expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))") expect(runtime.instructions()).not.toContain("tools.records.lookup(input:") }) @@ -1136,7 +1147,7 @@ describe("CodeMode public contract", () => { CodeMode.make({ tools, discovery: { catalogBudget: 0 }, - }).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`), + }).execute(`return search({ query: "order", limit: 0.5 })`), ) expect(result.ok).toBe(false) if (result.ok) return @@ -1144,9 +1155,7 @@ describe("CodeMode public contract", () => { for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) { const invalidOffset = await Effect.runPromise( - CodeMode.make({ tools }).execute( - `return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`, - ), + CodeMode.make({ tools }).execute(`return search({ query: "order", offset: ${JSON.stringify(offset)} })`), ) expect(invalidOffset.ok).toBe(false) if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput") @@ -1197,8 +1206,4 @@ describe("CodeMode public contract", () => { } expect(elapsedMs).toBeLessThan(3_000) }) - - test("reserves the discovery namespace", () => { - expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/) - }) }) diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 0de3dc3ea0..ca71226a57 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -41,7 +41,7 @@ describe("Object.keys over tool references", () => { const namespaces = Object.keys(tools) return { namespaces, count: namespaces.length } `), - ).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 }) + ).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 }) }) test("enumerates tool names at a nested namespace", async () => { @@ -52,8 +52,8 @@ describe("Object.keys over tool references", () => { expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([]) }) - test("the internal discovery namespace enumerates its callable surface", async () => { - expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"]) + test("search is a global built-in function", async () => { + expect(await value(`return typeof search`)).toBe("function") }) test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => { @@ -68,7 +68,7 @@ describe("Object.keys over tool references", () => { const failure = await error(`return Object.${method}(tools)`) expect(failure.kind).toBe("InvalidDataValue") expect(failure.message).toContain( - `Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + `Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`, ) } const nested = await error(`return Object.entries(tools.github)`) @@ -146,7 +146,7 @@ describe("for...in", () => { } return names `), - ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"]) + ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"]) }) test("unsupported values fail with a hint at for...of and Object.keys", async () => { diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 608019da46..2d256393a2 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -377,7 +377,7 @@ describe("OpenAPI.fromSpec", () => { runtime .execute( ` - return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 }) + return search({ query: "global health", namespace: "opencode", limit: 1 }) `, ) .pipe(Effect.provide(layer)), diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index c8046a6967..33c6e22361 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -302,9 +302,12 @@ describe("CodeMode-specific string behavior", () => { expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"') }) - test("trimLeft/trimRight alias trimStart/trimEnd", async () => { - expect(await value(`return " x ".trimLeft()`)).toBe("x ") - expect(await value(`return " x ".trimRight()`)).toBe(" x") + test("does not expose obsolete string aliases", async () => { + expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([ + "undefined", + "undefined", + "undefined", + ]) }) }) diff --git a/packages/codemode/test/promise-test262.test.ts b/packages/codemode/test/promise-test262.test.ts index 500b7ae45d..e416ec942c 100644 --- a/packages/codemode/test/promise-test262.test.ts +++ b/packages/codemode/test/promise-test262.test.ts @@ -16,8 +16,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { CodeMode } from "../src/index.js" -const execute = (code: string) => - Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } })) +const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } })) const value = async (code: string) => { const result = await execute(code) @@ -450,7 +449,10 @@ describe("Test262 async functions and await", () => { const promises = [declaration(), expression(), arrow()] return [promises.map((item) => item instanceof Promise), await Promise.all(promises)] `), - ).toEqual([[true, true, true], [1, 2, 3]]) + ).toEqual([ + [true, true, true], + [1, 2, 3], + ]) }) test("async bodies adopt returns and reject throws before and after await", async () => { @@ -479,13 +481,7 @@ describe("Test262 async functions and await", () => { await observe(throwsAfter()), ] `), - ).toEqual([ - ["body"], - ["fulfilled", 42], - ["fulfilled", 43], - ["rejected", 1], - ["rejected", 2], - ]) + ).toEqual([["body"], ["fulfilled", 42], ["fulfilled", 43], ["rejected", 1], ["rejected", 2]]) }) test("default-parameter throws reject instead of escaping the call", async () => { @@ -577,13 +573,14 @@ describe("Test262 async functions and await", () => { }) describe("Test262 expected Promise conformance", () => { - for (const name of ["all", "allSettled", "race"] as const) { - test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => { + for (const name of ["all", "allSettled", "race", "any"] as const) { + test(`Promise.${name} rejects invalid input with TypeError`, async () => { // Sources: // test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js // test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js // test/built-ins/Promise/allSettled/iter-arg-is-number-reject.js // test/built-ins/Promise/race/iter-arg-is-number-reject.js + // test/built-ins/Promise/any/iter-arg-is-number-reject.js expect( await value(` try { @@ -599,7 +596,7 @@ describe("Test262 expected Promise conformance", () => { }) } - test.failing("Promise.all consumes sparse positions as undefined", async () => { + test("Promise.all consumes sparse positions as undefined", async () => { // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) expect( await value(` @@ -611,7 +608,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([2, true, 1]) }) - test.failing("Promise.allSettled consumes sparse positions as undefined", async () => { + test("Promise.allSettled consumes sparse positions as undefined", async () => { // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) expect( await value(` @@ -623,7 +620,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([2, "fulfilled", true, { status: "fulfilled", value: 1 }]) }) - test.failing("Promise.race consumes a sparse first position as undefined", async () => { + test("Promise.race consumes a sparse first position as undefined", async () => { // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) expect( await value(` @@ -634,7 +631,18 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) - test.failing("Promise.all settles after reactions attached to its inputs", async () => { + test("Promise.any consumes a sparse first position as an undefined fulfillment", async () => { + // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) + expect( + await value(` + const input = [] + input[1] = Promise.reject("loses") + return (await Promise.any(input)) === undefined + `), + ).toBe(true) + }) + + test("Promise.all settles after reactions attached to its inputs", async () => { // Sources: // test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js // test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js @@ -653,7 +661,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5]) }) - test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => { + test("Promise.allSettled settles after reactions attached to its inputs", async () => { // Sources: // test/built-ins/Promise/allSettled/resolved-sequence.js // test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js @@ -674,7 +682,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5]) }) - test.failing("Promise.race settles in a reaction after its winning input", async () => { + test("Promise.race settles in a reaction after its winning input", async () => { // Sources: // test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js // test/built-ins/Promise/race/resolved-sequence-extra-ticks.js @@ -692,7 +700,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5]) }) - test.failing("then reactions route and propagate fulfillment and rejection", async () => { + test("then reactions route and propagate fulfillment and rejection", async () => { // Sources: // test/built-ins/Promise/prototype/then/prfm-fulfilled.js // test/built-ins/Promise/prototype/then/prfm-rejected.js @@ -726,7 +734,7 @@ describe("Test262 expected Promise conformance", () => { ]) }) - test.failing("then reactions preserve breadth-first queue order", async () => { + test("then reactions preserve breadth-first queue order", async () => { // Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js expect( await value(` @@ -741,7 +749,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) }) - test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => { + test("then rejects direct self-resolution for fulfilled and rejected sources", async () => { // Sources: // test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js // test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js @@ -761,7 +769,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual(["TypeError", "TypeError"]) }) - test.failing("catch delegates rejection handling and preserves fulfillment", async () => { + test("catch delegates rejection handling and preserves fulfillment", async () => { // Sources: // test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js // test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js @@ -776,7 +784,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 4]) }) - test.failing("finally preserves or replaces the original settlement", async () => { + test("finally preserves or replaces the original settlement", async () => { // Sources: // test/built-ins/Promise/prototype/finally/resolution-value-no-override.js // test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js @@ -799,7 +807,109 @@ describe("Test262 expected Promise conformance", () => { ]) }) - test.failing("await always resumes in a later reaction and interleaves async functions", async () => { + test("then ignores non-callable handlers", async () => { + // Sources: + // test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T1.js + // test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T2.js + // test/built-ins/Promise/prototype/then/S25.4.5.3_A5.1_T1.js + // test/built-ins/Promise/prototype/then/S25.4.5.3_A5.2_T1.js + // (adapted: only non-callable handlers are probed; callables that are not plain + // functions, such as tool references, intentionally throw in CodeMode) + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] } + } + return await Promise.all([ + observe(Promise.resolve(1).then(2)), + observe(Promise.resolve(4).then(null, null)), + observe(Promise.resolve(5).then({}, "x")), + observe(Promise.reject(3).then(null, "x")), + observe(Promise.reject(6).then(7, {})), + ]) + `), + ).toEqual([ + ["fulfilled", 1], + ["fulfilled", 4], + ["fulfilled", 5], + ["rejected", 3], + ["rejected", 6], + ]) + }) + + test("finally waits for a returned promise and preserves or replaces settlement", async () => { + // Sources: + // test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js + // test/built-ins/Promise/prototype/finally/rejected-observable-then-calls.js + // test/built-ins/Promise/prototype/finally/resolution-value-no-override.js + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] } + } + const order = [] + const cleanup = async () => { + await Promise.resolve() + order.push("cleanup") + } + const settled = await Promise.resolve("kept").finally(() => cleanup()) + order.push("settled:" + settled) + return [ + await observe(Promise.resolve(1).finally(() => Promise.resolve(99))), + order, + await observe(Promise.resolve(2).finally(() => Promise.reject(3))), + await observe(Promise.reject(4).finally(() => Promise.resolve(99))), + ] + `), + ).toEqual([ + ["fulfilled", 1], + ["cleanup", "settled:kept"], + ["rejected", 3], + ["rejected", 4], + ]) + }) + + test("then adopts a returned rejected promise", async () => { + // Sources: + // test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js + // test/built-ins/Promise/resolve/resolve-promise.js + // (adapted: the fulfillment handler returns an already-rejected promise instead of + // throwing, and the rejection handler recovers with a fulfilled promise) + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] } + } + return await Promise.all([ + observe(Promise.resolve(1).then(() => Promise.reject("bad"))), + observe(Promise.reject(2).then(undefined, () => Promise.resolve("ok"))), + ]) + `), + ).toEqual([ + ["rejected", "bad"], + ["fulfilled", "ok"], + ]) + }) + + test("independent reactions on one source each observe the same settlement", async () => { + // Source: test/built-ins/Promise/prototype/then/S25.4.4_A2.1_T1.js + // (adapted: the multiple-reactions family is asserted through the values every + // reaction returns instead of a shared completion counter) + expect( + await value(` + const fulfilled = Promise.resolve(7) + const rejected = Promise.reject(8) + return await Promise.all([ + fulfilled.then((value) => "first:" + value), + fulfilled.then((value) => "second:" + value), + rejected.catch((reason) => "first:" + reason), + rejected.catch((reason) => "second:" + reason), + ]) + `), + ).toEqual(["first:7", "second:7", "first:8", "second:8"]) + }) + + test("await always resumes in a later reaction and interleaves async functions", async () => { // Sources: // test/language/expressions/await/async-await-interleaved.js // test/language/expressions/await/await-non-promise.js @@ -814,7 +924,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual(["first:1", "second:1", "first:2", "second:2"]) }) - test.failing("an async function rejects when it resolves with its own promise", async () => { + test("an async function rejects when it resolves with its own promise", async () => { // Adapted from the self-resolution requirement represented by: // test/built-ins/Promise/resolve-self.js // test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js @@ -904,3 +1014,410 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) }) + +describe("Test262 Promise.any", () => { + test("is a callable static that returns a promise", async () => { + // Sources: + // test/built-ins/Promise/any/is-function.js + // test/built-ins/Promise/any/returns-promise.js + expect( + await value(` + const promise = Promise.any([1]) + return [typeof Promise.any, promise instanceof Promise, await promise] + `), + ).toEqual(["function", true, 1]) + }) + + test("fulfills with the first fulfilled member, ignoring rejections", async () => { + // Sources: + // test/built-ins/Promise/any/resolved-sequence-mixed.js + // test/built-ins/Promise/any/resolved-sequence-with-rejections.js + // test/built-ins/Promise/any/reject-ignored-immed.js + expect( + await value(` + return [ + await Promise.any([Promise.reject("a"), Promise.resolve(1), Promise.resolve(2)]), + await Promise.any([Promise.reject("a"), "plain", Promise.reject("b")]), + ] + `), + ).toEqual([1, "plain"]) + }) + + test("a fulfillment wins over a later rejection of another member", async () => { + // Sources: + // test/built-ins/Promise/any/resolve-ignores-late-rejection.js + // test/built-ins/Promise/any/resolve-ignores-late-rejection-deferred.js + expect( + await value(` + let rejectLate + const late = new Promise((_, reject) => { rejectLate = reject }) + const result = await Promise.any([late, Promise.resolve("won")]) + rejectLate("too late") + return result + `), + ).toBe("won") + }) + + test("rejects with an AggregateError carrying the reasons in input order", async () => { + // Sources: + // test/built-ins/Promise/any/reject-all-mixed.js + // test/built-ins/Promise/any/reject-immed.js + // test/built-ins/Promise/any/reject-deferred.js + expect( + await value(` + let rejectLate + const late = new Promise((_, reject) => { rejectLate = reject }) + const aggregate = Promise.any([Promise.reject("first"), late, Promise.reject("third")]) + rejectLate("second") + try { + await aggregate + return "fulfilled" + } catch (error) { + return { + isAggregate: error instanceof AggregateError, + isError: error instanceof Error, + name: error.name, + message: error.message, + errors: error.errors, + } + } + `), + ).toEqual({ + isAggregate: true, + isError: true, + name: "AggregateError", + message: "All promises were rejected", + errors: ["first", "second", "third"], + }) + }) + + test("rejects an empty input with an empty AggregateError", async () => { + // Source: test/built-ins/Promise/any/iter-arg-is-empty-iterable-reject.js + expect( + await value(` + try { + await Promise.any([]) + return "fulfilled" + } catch (error) { + return [error instanceof AggregateError, error.errors.length] + } + `), + ).toEqual([true, 0]) + }) + + test("consumes a string input as its characters", async () => { + // Source: test/built-ins/Promise/any/iter-arg-is-string-resolve.js + expect(await value(`return await Promise.any("abc")`)).toBe("a") + }) + + test("rejects an empty string input with an empty AggregateError", async () => { + // Source: test/built-ins/Promise/any/iter-arg-is-empty-string-reject.js + expect( + await value(` + try { + await Promise.any("") + return "fulfilled" + } catch (error) { + return [error instanceof AggregateError, error.errors.length] + } + `), + ).toEqual([true, 0]) + }) + + test("fulfills with the first member that does not reject", async () => { + // Sources: + // test/built-ins/Promise/any/resolve-from-reject-catch.js + // test/built-ins/Promise/any/resolve-from-resolve-reject-catch.js + expect( + await value(` + return await Promise.any([ + Promise.reject("a"), + new Promise((resolve, reject) => reject("b")), + Promise.all([Promise.reject("c")]), + Promise.resolve(Promise.reject("d").catch((reason) => reason)), + ]) + `), + ).toBe("d") + }) + + test("settles after reactions attached to its inputs", async () => { + // Source: test/built-ins/Promise/any/resolved-sequence.js + expect( + await value(` + const sequence = [1] + const input = Promise.resolve(1) + const aggregate = Promise.any([input]) + aggregate.then(() => sequence.push(4)) + input.then(() => sequence.push(3)).then(() => sequence.push(5)) + sequence.push(2) + await aggregate + await Promise.resolve() + return sequence + `), + ).toEqual([1, 2, 3, 4, 5]) + }) +}) + +describe("Test262 AggregateError", () => { + test("constructs from an errors collection and an optional message", async () => { + // Sources: + // test/built-ins/AggregateError/errors-iterabletolist.js + // test/built-ins/AggregateError/message-undefined-no-prop.js + expect( + await value(` + const input = ["x", "y"] + const withMessage = new AggregateError(input, "msg") + const bare = new AggregateError([]) + return [ + withMessage.name, + withMessage.message, + withMessage.errors, + withMessage.errors !== input, + withMessage instanceof AggregateError, + withMessage instanceof Error, + bare.message, + bare.errors, + ] + `), + ).toEqual(["AggregateError", "msg", ["x", "y"], true, true, true, "", []]) + }) + + test("rejects a non-collection errors argument with TypeError", async () => { + // Source: test/built-ins/AggregateError/errors-iterabletolist-failures.js + expect( + await value(` + try { + new AggregateError(42) + return "constructed" + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) + + test("is callable without new", async () => { + // Source: test/built-ins/AggregateError/newtarget-is-undefined.js + expect( + await value(` + const error = AggregateError(["x"], "m") + return [error instanceof AggregateError, error instanceof Error, error.name, error.message, error.errors] + `), + ).toEqual([true, true, "AggregateError", "m", ["x"]]) + }) + + test("coerces a non-string message to a string", async () => { + // Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the + // upstream object-with-toString case is omitted because the sandbox has no user toString dispatch) + expect( + await value(` + return [ + new AggregateError([], 42).message, + new AggregateError([], false).message, + new AggregateError([], true).message, + new AggregateError([], null).message, + ] + `), + ).toEqual(["42", "false", "true", "null"]) + }) +}) + +describe("Test262 Promise constructor", () => { + test("constructs a promise, handing the executor callable resolve/reject", async () => { + // Sources: + // test/built-ins/Promise/constructor.js + // test/built-ins/Promise/exec-args.js + expect( + await value(` + let observed + const promise = new Promise((resolve, reject) => { + observed = [typeof resolve, typeof reject] + resolve("done") + }) + return [promise instanceof Promise, observed, await promise] + `), + ).toEqual([true, ["function", "function"], "done"]) + }) + + test("a missing or non-callable executor is a TypeError", async () => { + // Source: test/built-ins/Promise/executor-not-callable.js + expect( + await value(` + const outcomes = [] + for (const make of [() => new Promise(), () => new Promise(1), () => new Promise({})]) { + try { + make() + outcomes.push("constructed") + } catch (error) { + outcomes.push(error.name) + } + } + return outcomes + `), + ).toEqual(["TypeError", "TypeError", "TypeError"]) + }) + + test("resolves immediately or later through an escaping resolver", async () => { + // Sources: + // test/built-ins/Promise/resolve-non-thenable-immed.js + // test/built-ins/Promise/resolve-non-thenable-deferred.js + // test/built-ins/Promise/create-resolving-functions-resolve.js + expect( + await value(` + let settle + const deferred = new Promise((resolve) => { settle = resolve }) + const immediate = new Promise((resolve) => resolve("now")) + settle("later") + return [await immediate, await deferred] + `), + ).toEqual(["now", "later"]) + }) + + test("rejects through reject and through an abrupt executor completion", async () => { + // Sources: + // test/built-ins/Promise/reject-via-fn-immed.js + // test/built-ins/Promise/reject-via-abrupt.js + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason.message ?? reason] } + } + return [ + await observe(new Promise((_, reject) => reject("nope"))), + await observe(new Promise(() => { throw new Error("boom") })), + ] + `), + ).toEqual([ + ["rejected", "nope"], + ["rejected", "boom"], + ]) + }) + + test("only the first settlement counts", async () => { + // Sources: + // test/built-ins/Promise/reject-ignored-via-fn-immed.js + // test/built-ins/Promise/resolve-ignored-via-fn-immed.js + expect( + await value(` + return [ + await new Promise((resolve, reject) => { resolve("first"); reject("second"); resolve("third") }), + await new Promise((resolve) => { resolve(resolve("inner") === undefined ? "unreached" : "also unreached") }), + ] + `), + ).toEqual(["first", "inner"]) + }) + + test("escaped resolvers keep first-settle-wins in both directions", async () => { + // Sources: + // test/built-ins/Promise/reject-ignored-via-fn-deferred.js + // test/built-ins/Promise/resolve-ignored-via-fn-deferred.js + expect( + await value(` + let resolveRejected, rejectRejected + const rejected = new Promise((resolve, reject) => { resolveRejected = resolve; rejectRejected = reject }) + rejectRejected("first") + const lateResolve = resolveRejected("late") + let resolveFulfilled, rejectFulfilled + const fulfilled = new Promise((resolve, reject) => { resolveFulfilled = resolve; rejectFulfilled = reject }) + resolveFulfilled() + const lateReject = rejectFulfilled(new Promise(() => {})) + try { + await rejected + return "fulfilled" + } catch (reason) { + return [reason, lateResolve === undefined, (await fulfilled) === undefined, lateReject === undefined] + } + `), + ).toEqual(["first", true, true, true]) + }) + + test("a queued reaction chain observes a later rejection through a handler-less then", async () => { + // Sources: + // test/built-ins/Promise/reject-via-fn-immed-queue.js + // test/built-ins/Promise/reject-via-fn-deferred-queue.js + // test/built-ins/Promise/reject-via-abrupt-queue.js + expect( + await value(` + const observe = (promise) => promise.then(() => "wrong").then(() => "also wrong", (reason) => "caught:" + reason) + let reject + const deferred = new Promise((_, r) => { reject = r }) + const chained = observe(deferred) + reject("boom") + return [ + await observe(new Promise((_, r) => r("immed"))), + await chained, + await observe(new Promise(() => { throw "abrupt" })), + ] + `), + ).toEqual(["caught:immed", "caught:boom", "caught:abrupt"]) + }) + + test("an exception after resolve is ignored", async () => { + // Source: test/built-ins/Promise/exception-after-resolve-in-executor.js + expect(await value(`return await new Promise((resolve) => { resolve("kept"); throw new Error("dropped") })`)).toBe( + "kept", + ) + }) + + test("resolving with a promise adopts its settlement", async () => { + // Sources: + // test/built-ins/Promise/resolve-thenable-immed.js (promise-adoption portion) + // test/built-ins/Promise/all/S25.4.4.1_A2.3_T1.js (resolution adoption semantics) + expect( + await value(` + const adoptedValue = await new Promise((resolve) => resolve(Promise.resolve("adopted"))) + try { + await new Promise((resolve) => resolve(Promise.reject("bad"))) + return [adoptedValue, "fulfilled"] + } catch (reason) { + return [adoptedValue, reason] + } + `), + ).toEqual(["adopted", "bad"]) + }) + + test("resolving with the promise itself rejects with TypeError", async () => { + // Source: test/built-ins/Promise/resolve-self.js + expect( + await value(` + let settle + const promise = new Promise((resolve) => { settle = resolve }) + settle(promise) + try { + await promise + return "fulfilled" + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) + + test("executor runs synchronously before the constructor returns", async () => { + // Source: test/built-ins/Promise/executor-call-context-strict.js (synchronous Call(executor) step) + expect( + await value(` + const sequence = [] + sequence.push("before") + new Promise((resolve) => { sequence.push("executor"); resolve() }) + sequence.push("after") + return sequence + `), + ).toEqual(["before", "executor", "after"]) + }) + + test.failing("calling Promise without new throws TypeError", async () => { + // Source: test/built-ins/Promise/undefined-newtarget.js + // The sandbox currently reports a generic Error ("Only tools are callable in CodeMode."). + expect( + await value(` + try { + Promise(() => {}) + return "called" + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) +}) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 847cd6fb15..5ce4d003d5 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -3,8 +3,9 @@ import { Effect, Schema } from "effect" import { CodeMode, Tool, toolError } from "../src/index.js" // Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on -// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are -// ordinary functions over arbitrary arrays mixing promises and plain values. +// supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are +// ordinary functions over arbitrary arrays mixing promises and plain values, and +// .then/.catch/.finally chain reactions onto any promise. type Trace = { starts: Array @@ -184,7 +185,7 @@ describe("first-class promise values", () => { expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }]) }) - test("await of a non-promise value is a passthrough no-op", async () => { + test("await of a non-promise value passes it through unchanged", async () => { expect(await value(`return await 42`)).toBe(42) expect(await value(`const x = await "s"; return x`)).toBe("s") expect(await value(`return await null`)).toBeNull() @@ -935,16 +936,124 @@ describe("timeout interruption of forked calls", () => { }) }) -describe("unsupported promise surface", () => { - test(".then/.catch/.finally give a clear await-instead error", async () => { - for (const method of ["then", "catch", "finally"]) { - const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`) - expect(diagnostic.kind).toBe("UnsupportedSyntax") - expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`) - expect(diagnostic.message).toContain("await") - } +describe("promise chaining", () => { + test("then transforms tool results and adopts returned promises across a chain", async () => { + expect( + await value(` + return await tools.host + .sleepy({ id: 2 }) + .then((id) => tools.host.sleepy({ id: id + 1 })) + .then((id) => id * 10) + `), + ).toBe(30) }) + test("handlers are deferred and run in attach order", async () => { + expect( + await value(` + const order = [] + const promise = Promise.resolve(1) + promise.then(() => order.push("h1")) + promise.then(() => order.push("h2")) + order.push("sync") + await promise + return order + `), + ).toEqual(["sync", "h1", "h2"]) + }) + + test("catch recovers a tool failure and preserves fulfillment", async () => { + expect( + await value(` + return [ + await tools.host.fail({}).catch((error) => error.message), + await tools.host.sleepy({ id: 4 }).catch(() => "unused"), + ] + `), + ).toEqual(["Lookup refused", 4]) + }) + + test("finally observes settlement without changing the value", async () => { + expect( + await value(` + const events = [] + const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup")) + return [result, events] + `), + ).toEqual([5, ["cleanup"]]) + }) + + test("a settled, un-awaited rejected chain tail warns exactly once", async () => { + const result = await run(` + Promise.reject(new Error("boom")).then((value) => value) + await Promise.resolve() + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + // The source rejection belongs to the chain (no warning); only the derived tail warns. + expect(result.warnings).toStrictEqual([ + { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" }, + ]) + }) + + test("a catch handler silences the chain's rejection warning", async () => { + const result = await run(` + Promise.reject(new Error("boom")).catch(() => "handled") + await Promise.resolve() + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.warnings).toBeUndefined() + }) + + test("non-plain-function handlers fail loudly instead of being ignored", async () => { + const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`) + expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions") + }) + + test("chaining methods are opaque references until called", async () => { + expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function") + }) +}) + +describe("combinator settlement timing", () => { + test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => { + // The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning + // program abandons it while still pending: interrupted like any pending work, so no + // rejection warning survives - the member itself was observed by the combinator. + const result = await run(` + Promise.all([Promise.reject(new Error("boom"))]) + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toBeUndefined() + }) + + test("a combinator settles one reaction turn after its members, as in V8", async () => { + // Regression for the race winner flip: Promise.all's settlement burns a reaction turn, + // so a plain resolved value entered in the same race wins, and a fail-fast aggregate + // cannot beat it into rejection. + expect( + await value(` + const pending = tools.host.sleepy({ id: 9, ms: 60000 }) + const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)]) + try { + const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")]) + return [winner, "fulfilled", raced] + } catch (reason) { + return [winner, "rejected", reason] + } + `), + ).toEqual([2, "fulfilled", "ok"]) + }) +}) + +describe("unsupported promise surface", () => { test("other property reads on a promise hint at the missing await", async () => { const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`) expect(diagnostic.kind).toBe("InvalidDataValue") @@ -953,15 +1062,179 @@ describe("unsupported promise surface", () => { }) test("unknown Promise statics list what is available", async () => { - const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`) - expect(diagnostic.message).toContain("Promise.any is not available") - expect(diagnostic.message).toContain("Promise.allSettled") - }) - - test("new Promise(...) points at tool calls instead", async () => { - const diagnostic = await error(`return new Promise((resolve) => resolve(1))`) - expect(diagnostic.kind).toBe("UnsupportedSyntax") - expect(diagnostic.message).toContain("new Promise(...) is not supported") - expect(diagnostic.message).toContain("already return promises") + const diagnostic = await error(`return await Promise.withResolvers()`) + expect(diagnostic.message).toContain("Promise.withResolvers is not available") + expect(diagnostic.message).toContain("Promise.any") + }) +}) + +describe("Promise.any", () => { + test("first tool success wins; failing and losing calls are handled silently", async () => { + const trace = makeTrace() + const result = await run( + ` + const winner = await Promise.any([ + tools.host.fail({}), + tools.host.sleepy({ id: 1, ms: 5 }), + tools.host.sleepy({ id: 2, ms: 60000 }), + ]) + return winner + `, + { trace }, + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe(1) + // The slow loser stays execution-owned and is interrupted at completion; the tool + // failure was observed by the aggregate, so no rejection warning survives. + expect(result.warnings).toBeUndefined() + expect(trace.interrupted).toBe(1) + }) + + test("all members failing rejects with catch-normalized reasons in input order", async () => { + expect( + await value(` + try { + await Promise.any([tools.host.fail({}), Promise.reject("plain")]) + return "fulfilled" + } catch (error) { + return [error.name, error.errors.map((reason) => reason.message ?? reason)] + } + `), + ).toEqual(["AggregateError", ["Lookup refused", "plain"]]) + }) + + test("settles one reaction turn after its deciding member, as in V8", async () => { + expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2) + }) + + test("a tie is decided by settlement order, not input order", async () => { + // Handlers run in attach order, so `first` settles before `second` and wins + // despite its later input position - as in real JS. + expect( + await value(` + const first = Promise.resolve().then(() => "one") + const second = Promise.resolve().then(() => "two") + return await Promise.any([second, first]) + `), + ).toBe("one") + }) + + test("an abandoned rejecting aggregate is interrupted silently at the return", async () => { + const result = await run(` + Promise.any([Promise.reject(new Error("boom"))]) + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toBeUndefined() + }) +}) + +describe("promise construction", () => { + test("a deferred gate coordinates tool results across async functions", async () => { + expect( + await value(` + let openGate + const gate = new Promise((resolve) => { openGate = resolve }) + const worker = (async () => { + const id = await gate + return id * 2 + })() + openGate(await tools.host.sleepy({ id: 21, ms: 5 })) + return await worker + `), + ).toBe(42) + }) + + test("the .then(resolve) bridge settles a constructed promise", async () => { + expect( + await value(` + const bridged = new Promise((resolve, reject) => { + tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject) + }) + return await bridged + `), + ).toBe(7) + }) + + test("constructed promises participate in combinators", async () => { + expect( + await value(` + let settle + const manual = new Promise((resolve) => { settle = resolve }) + const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })]) + const all = Promise.all([manual, "plain"]) + const any = Promise.any([manual, new Promise(() => {})]) + settle("manual") + return [await race, await all, await any] + `), + ).toEqual(["manual", ["manual", "plain"], "manual"]) + }) + + test("resolving with a pending promise adopts its later settlement", async () => { + expect( + await value(` + let innerResolve, innerReject + const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve }))) + const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject }))) + innerResolve("later") + innerReject("bad") + try { + return [await adopted, await adoptedRejection] + } catch (reason) { + return [await adopted, reason] + } + `), + ).toEqual(["later", "bad"]) + }) + + test("an async executor's post-await resolve settles the promise", async () => { + expect( + await value(` + const result = new Promise(async (resolve) => { + const id = await tools.host.sleepy({ id: 5, ms: 5 }) + resolve(id * 2) + }) + return await result + `), + ).toBe(10) + }) + + test("a never-settled promise is abandoned silently at the return", async () => { + const result = await run(` + const forever = new Promise(() => {}) + forever.then(() => {}) + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toBeUndefined() + }) + + test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => { + const result = await run(` + new Promise((_, reject) => reject(new Error("dropped"))) + await Promise.resolve() + await Promise.resolve() + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toHaveLength(1) + expect(result.warnings?.[0].message).toContain("Unhandled rejection") + expect(result.warnings?.[0].message).toContain("dropped") + }) + + test("resolver functions cannot cross the data boundary", async () => { + const diagnostic = await error(` + let escaped + new Promise((resolve) => { escaped = resolve }) + return { escaped } + `) + expect(diagnostic.kind).toBe("InvalidDataValue") }) }) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 232c6dcb22..dea0e890ac 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -342,9 +342,7 @@ describe("JSDoc signatures in catalogs and search results", () => { const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) const search = async (query: string) => { - const result = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), - ) + const result = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`)) expect(result.ok).toBe(true) if (!result.ok) throw new Error("search failed") return result.value as { items: Array<{ path: string; signature: string }>; remaining: number } @@ -436,9 +434,7 @@ describe("non-identifier tool paths", () => { }) test("search results return callable bracket-notation paths and signatures", async () => { - const result = await Effect.runPromise( - runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`), - ) + const result = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library" })`)) expect(result.ok).toBe(true) if (!result.ok) throw new Error("search failed") diff --git a/packages/codemode/test/string-search-test262.test.ts b/packages/codemode/test/string-search-test262.test.ts index f332ea1671..5dcd63c8ba 100644 --- a/packages/codemode/test/string-search-test262.test.ts +++ b/packages/codemode/test/string-search-test262.test.ts @@ -49,12 +49,6 @@ * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js - * - test/annexB/built-ins/String/prototype/substr/start-negative.js - * - test/annexB/built-ins/String/prototype/substr/length-negative.js - * - test/annexB/built-ins/String/prototype/substr/length-positive.js - * - test/annexB/built-ins/String/prototype/substr/length-falsey.js - * - test/annexB/built-ins/String/prototype/substr/length-undef.js - * - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js * - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js * - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js * - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js @@ -460,67 +454,6 @@ const cases = [ expected: ["this_is_"], labels: ['#1: __string.substring(0,8) === "this_is_"'], }, - { - path: "test/annexB/built-ins/String/prototype/substr/start-negative.js", - code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`, - expected: ["c", "bc", "abc", "abc", "c"], - labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-negative.js", - code: `return [ - "abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4), - "abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4), - "abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4), - "abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4), - ]`, - expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], - labels: [ - "0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4", - "2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4", - ], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-positive.js", - code: `return [ - "abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4), - "abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4), - "abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4), - "abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4), - ]`, - expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""], - labels: [ - "0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1", - "2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1", - ], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js", - code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`, - expected: ["", "", "", ""], - labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/length-undef.js", - code: `return [ - "abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3), - "abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined), - ]`, - expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""], - labels: [ - "start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified", - "start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined", - ], - }, - { - path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", - code: `return [ - "\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2), - "\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2), - ]`, - expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"], - labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"], - }, { path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js", code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'], diff --git a/packages/codemode/tsconfig.json b/packages/codemode/tsconfig.json index fe5c4d217b..0cbc049d87 100644 --- a/packages/codemode/tsconfig.json +++ b/packages/codemode/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "noUncheckedIndexedAccess": false + "noUncheckedIndexedAccess": false, + "noUnusedLocals": true } } diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 886d76a0f1..8a199fc609 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,6 +1,6 @@ export * as EventV2 from "./event" -import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" +import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type { EventLog } from "@opencode-ai/schema/event-log" @@ -89,11 +89,6 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => { } } -export class SubscriberOverflowError extends Schema.TaggedErrorClass()( - "EventV2.SubscriberOverflow", - { capacity: Schema.Int }, -) {} - export const versionedType = Event.versionedType export const durable = Event.durable export const ephemeral = Event.ephemeral @@ -167,27 +162,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Event") {} -export const liveBounded = ( - events: Interface, - options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean }, -) => - Effect.gen(function* () { - const queue = yield* Queue.dropping(options.capacity) - const unsubscribe = yield* events.listen((event) => - options.accept && !options.accept(event) - ? Effect.void - : Queue.offer(queue, event).pipe( - Effect.flatMap((accepted) => - accepted - ? Effect.void - : Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid), - ), - ), - ) - yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid)) - return Stream.fromQueue(queue) - }) - export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect /** Maximum durable rows read per page while replaying or tailing an aggregate log. */ diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 8c0a0d55a2..6130fddf10 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -128,6 +128,8 @@ export const fffLayer = Layer.effect( Fff.create({ basePath: location.directory, aiMode: true, + disableMmapCache: true, + disableContentIndexing: true, }), catch: (cause) => cause, }).pipe( @@ -230,6 +232,13 @@ export const fffLayer = Layer.effect( }), ) -const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) +const layer = Layer.unwrap( + Effect.gen(function* () { + if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer + const location = yield* Location.Service + // Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree. + return location.vcs ? fffLayer : ripgrepLayer + }), +) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] }) diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index eea11c95f7..8ac4ed203b 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -189,16 +189,14 @@ const layer = Layer.effect( if (!dotgit) return undefined const cwd = path.dirname(dotgit) - const git = run(cwd, proc) - const topLevel = yield* git(["rev-parse", "--show-toplevel"]) - const gitDir = yield* git(["rev-parse", "--git-dir"]) - const commonDir = yield* git(["rev-parse", "--git-common-dir"]) - if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined + const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"]) + const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/) + if (!gitDir || !commonDir) return undefined return new Repository({ - worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd), - gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)), - commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)), + worktree: AbsolutePath.make(topLevel ? resolvePath(cwd, topLevel) : cwd), + gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir)), + commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir)), }) }) diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts new file mode 100644 index 0000000000..8790cefba2 --- /dev/null +++ b/packages/core/src/github-copilot/models.ts @@ -0,0 +1,214 @@ +export * as CopilotModels from "./models" + +import { Money } from "@opencode-ai/schema/money" +import { Option, Schema } from "effect" +import { ModelV2 } from "../model" +import { ProviderV2 } from "../provider" + +const RemoteModel = Schema.Struct({ + model_picker_enabled: Schema.Boolean, + id: Schema.String, + name: Schema.String, + version: Schema.String, + supported_endpoints: Schema.optional(Schema.Array(Schema.String)), + policy: Schema.optional(Schema.Struct({ state: Schema.optional(Schema.String) })), + billing: Schema.optional( + Schema.Struct({ + token_prices: Schema.optional( + Schema.Struct({ + batch_size: Schema.Number, + default: Schema.Struct({ + cache_price: Schema.Number, + input_price: Schema.Number, + output_price: Schema.Number, + }), + }), + ), + }), + ), + capabilities: Schema.Struct({ + family: Schema.String, + limits: Schema.optional( + Schema.Struct({ + max_context_window_tokens: Schema.optional(Schema.Number), + max_output_tokens: Schema.optional(Schema.Number), + max_prompt_tokens: Schema.optional(Schema.Number), + vision: Schema.optional( + Schema.Struct({ + max_prompt_image_size: Schema.Number, + max_prompt_images: Schema.Number, + supported_media_types: Schema.Array(Schema.String), + }), + ), + }), + ), + supports: Schema.Struct({ + adaptive_thinking: Schema.optional(Schema.Boolean), + max_thinking_budget: Schema.optional(Schema.Number), + min_thinking_budget: Schema.optional(Schema.Number), + reasoning_effort: Schema.optional(Schema.Array(Schema.String)), + streaming: Schema.optional(Schema.Boolean), + structured_outputs: Schema.optional(Schema.Boolean), + tool_calls: Schema.optional(Schema.Boolean), + vision: Schema.optional(Schema.Boolean), + }), + }), +}) + +const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) }) +const decodeResponse = Schema.decodeUnknownSync(Response) +const decodeModel = Schema.decodeUnknownOption(RemoteModel) + +type RemoteModel = typeof RemoteModel.Type +type UsableModel = RemoteModel & { + capabilities: RemoteModel["capabilities"] & { + limits: NonNullable & { + max_output_tokens: number + max_prompt_tokens: number + } + supports: RemoteModel["capabilities"]["supports"] & { tool_calls: boolean } + } +} + +export async function get(baseURL: string, headers: RequestInit["headers"], existing: readonly ModelV2.Info[]) { + const response = await fetch(`${baseURL}/models`, { + headers, + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok) throw new Error(`Failed to fetch Copilot models: ${response.status}`) + + const remote = new Map( + decodeResponse(await response.json()).data.flatMap((raw) => { + const model = Option.getOrUndefined(decodeModel(raw)) + return model && usable(model) ? ([[model.id, model]] as const) : [] + }), + ) + const result = new Map(existing.map((model) => [model.id, model])) + + // Keep aliases and local metadata, but only when their advertised API model + // still exists. A partial or malformed item cannot create a broken model. + for (const [id, model] of result) { + const current = remote.get(model.modelID) + if (!current) { + result.delete(id) + continue + } + result.set(id, build(id, current, baseURL, model)) + } + + for (const [id, model] of remote) { + const key = ModelV2.ID.make(id) + if (result.has(key)) continue + result.set(key, build(key, model, baseURL)) + } + + return result +} + +function usable(model: RemoteModel): model is UsableModel { + return ( + model.policy?.state !== "disabled" && + model.capabilities.limits?.max_output_tokens !== undefined && + model.capabilities.limits.max_prompt_tokens !== undefined && + model.capabilities.supports.tool_calls !== undefined + ) +} + +function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: ModelV2.Info) { + const messages = remote.supported_endpoints?.includes("/v1/messages") ?? false + const endpoint = messages + ? "messages" + : remote.supported_endpoints?.includes("/responses") + ? "responses" + : remote.supported_endpoints?.includes("/chat/completions") + ? "chat" + : undefined + const image = + (remote.capabilities.supports.vision ?? false) || + (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) + const prices = remote.billing?.token_prices + // Copilot reports AIC per billing batch; OpenCode stores USD per million tokens. + const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0 + const version = remote.version.startsWith(`${remote.id}-`) + ? remote.version.slice(remote.id.length + 1) + : remote.version + const released = previous?.time.released || Date.parse(version) + + return ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id), + id, + modelID: ModelV2.ID.make(remote.id), + providerID: ProviderV2.ID.githubCopilot, + family: previous?.family ?? ModelV2.Family.make(remote.capabilities.family), + name: previous?.name ?? remote.name, + package: ProviderV2.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"), + settings: ProviderV2.mergeOverlay(previous?.settings, { + baseURL: messages ? `${baseURL}/v1` : baseURL, + ...(endpoint ? { endpoint } : {}), + }), + headers: previous?.headers, + body: previous?.body, + capabilities: { + tools: remote.capabilities.supports.tool_calls, + input: image ? ["text", "image"] : ["text"], + output: ["text"], + }, + variants: variants(remote, messages), + time: { released: Number.isFinite(released) ? released : 0 }, + cost: [ + { + input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion), + output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion), + cache: { + read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion), + write: Money.USDPerMillionTokens.zero, + }, + }, + ], + status: "active", + enabled: remote.model_picker_enabled, + limit: { + context: remote.capabilities.limits.max_context_window_tokens ?? remote.capabilities.limits.max_prompt_tokens, + input: remote.capabilities.limits.max_prompt_tokens, + output: remote.capabilities.limits.max_output_tokens, + }, + }) +} + +function variants(remote: UsableModel, messages: boolean): ModelV2.Info["variants"] { + const efforts = remote.capabilities.supports.reasoning_effort ?? [] + if (!messages && efforts.length) { + return efforts.map((effort) => ({ + id: ModelV2.VariantID.make(effort), + settings: { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + })) + } + if (efforts.length && remote.capabilities.supports.adaptive_thinking) { + return efforts.map((effort) => ({ + id: ModelV2.VariantID.make(effort), + settings: { + thinking: { + type: "adaptive", + ...(remote.id.includes("opus-4.7") ? { display: "summarized" } : {}), + }, + effort, + }, + })) + } + const max = remote.capabilities.supports.max_thinking_budget + if (max === undefined) return [] + return [ + { + id: ModelV2.VariantID.make("max"), + settings: { thinking: { type: "enabled", budgetTokens: max - 1 } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { thinking: { type: "enabled", budgetTokens: Math.floor(max / 2) } }, + }, + ] +} diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 4e57d390d1..236d027f3f 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -303,7 +303,7 @@ const layer = Layer.effect( } const project = (entry: Entry, connections: IntegrationConnection.Info[]) => - new Info({ + Info.make({ id: entry.ref.id, name: entry.ref.name, methods: entry.methods, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 610d14e3c3..4054873851 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -21,10 +21,14 @@ import { ToolHooks } from "./tool/hooks" import { PluginHooks } from "./plugin/hooks" export interface Interface { - readonly activate: (plugins: readonly Plugin[]) => Effect.Effect + readonly activate: (plugins: readonly Versioned[]) => Effect.Effect readonly list: () => Effect.Effect } +export interface Versioned extends Plugin { + readonly version: string +} + export class Service extends Context.Service()("@opencode/v2/Plugin") {} const layer = Layer.effect( @@ -32,11 +36,11 @@ const layer = Layer.effect( Effect.gen(function* () { const events = yield* EventV2.Service const scope = yield* Scope.make() - const active = new Map() + const active = new Map() const lock = Semaphore.makeUnsafe(1) let host: Parameters[0] - const load = Effect.fnUntraced(function* (plugin: Plugin) { + const load = Effect.fnUntraced(function* (plugin: Versioned) { const child = yield* Scope.fork(scope) const inherit = yield* State.inherit() const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe( @@ -55,7 +59,7 @@ const layer = Layer.effect( return undefined }) - const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Plugin[]) { + const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) { const definitions = plugins.map((plugin) => ({ ...plugin, id: ID.make(plugin.id) })) const ids = new Set() for (const definition of definitions) { @@ -65,6 +69,20 @@ const layer = Layer.effect( yield* lock.withPermit( Effect.gen(function* () { + const next = definitions.map((definition) => ({ id: definition.id, version: definition.version })) + const current = Array.from(active.values(), (entry) => ({ + id: entry.plugin.id, + version: entry.plugin.version, + })) + if ( + current.length === next.length && + current.every((definition, index) => { + const candidate = next[index] + return definition.id === candidate?.id && definition.version === candidate.version + }) + ) + return + yield* State.batch( Effect.gen(function* () { for (const definition of definitions) { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 3d2957e1ba..ec23dfd36b 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -26,7 +26,10 @@ export const ModelsDevPlugin = define({ }) yield* ctx.catalog.transform((catalog) => { for (const provider of loaded.data) { - catalog.provider.update(provider.info.id, (draft) => Object.assign(draft, provider.info)) + catalog.provider.update(provider.info.id, (draft) => { + Object.assign(draft, provider.info) + draft.integrationID = provider.info.id + }) for (const model of provider.models) { catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model)) } diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 716d69c10d..75c8b300af 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,14 +1,24 @@ export * as PluginPromise from "./promise" -import { Plugin } from "@opencode-ai/plugin/v2/effect" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin" import type { AnyTool } from "@opencode-ai/plugin/v2/tool" -import { Effect, Scope, Stream } from "effect" +import { Agent } from "@opencode-ai/schema/agent" +import { Integration } from "@opencode-ai/schema/integration" +import { Location } from "@opencode-ai/schema/location" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { AbsolutePath } from "@opencode-ai/schema/schema" +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Workspace } from "@opencode-ai/schema/workspace" +import { DateTime, Effect, Scope, Stream } from "effect" import { Tool } from "../tool/tool" type HostRegistration = { readonly dispose: Effect.Effect } type Registration = { readonly dispose: () => Promise } -type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin -type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context +type PromiseEvent = ReturnType extends AsyncIterable ? Event : never +type JsonValue = null | boolean | number | string | Array | { [key: string]: JsonValue } /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only @@ -19,8 +29,8 @@ type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context * preserves boot-time batching, so Promise-plugin transforms still coalesce * into one reload per domain. */ -export function fromPromise(plugin: PromisePlugin) { - return Plugin.define({ +export function fromPromise(plugin: Plugin) { + return define({ id: plugin.id, effect: (host) => Effect.gen(function* () { @@ -33,7 +43,7 @@ export function fromPromise(plugin: PromisePlugin) { dispose: () => Effect.runPromiseWith(context)(registration.dispose), })) - const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect).then(wire) const transform = (domain: { @@ -46,7 +56,7 @@ export function fromPromise(plugin: PromisePlugin) { }), ) - const context2: PromisePluginContext = { + const context2: Context = { options: host.options, agent: { list: (input) => run(host.agent.list(input)), @@ -60,11 +70,12 @@ export function fromPromise(plugin: PromisePlugin) { catalog: { provider: { list: (input) => run(host.catalog.provider.list(input)), - get: (input) => run(host.catalog.provider.get(input)), + get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })), }, model: { list: (input) => run(host.catalog.model.list(input)), - default: (input) => run(host.catalog.model.default(input)), + default: (input) => + run(host.catalog.model.default(input)).then((result) => ({ ...result, data: result.data ?? null })), }, transform: transform(host.catalog), reload: () => run(host.catalog.reload()), @@ -75,19 +86,48 @@ export function fromPromise(plugin: PromisePlugin) { reload: () => run(host.command.reload()), }, event: { - subscribe: () => Stream.toAsyncIterable(host.event.subscribe()), + subscribe: () => Stream.toAsyncIterable(host.event.subscribe().pipe(Stream.map(wireEvent))), }, integration: { list: (input) => run(host.integration.list(input)), - get: (input) => run(host.integration.get(input)), + get: (input) => + run(host.integration.get({ ...input, integrationID: Integration.ID.make(input.integrationID) })).then( + (result) => ({ ...result, data: result.data ?? null }), + ), connect: { - key: (input) => run(host.integration.connect.key(input)), - oauth: (input) => run(host.integration.connect.oauth(input)), + key: (input) => + run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })), + oauth: (input) => + run( + host.integration.connect.oauth({ + ...input, + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + }), + ), }, attempt: { - status: (input) => run(host.integration.attempt.status(input)), - complete: (input) => run(host.integration.attempt.complete(input)), - cancel: (input) => run(host.integration.attempt.cancel(input)), + status: (input) => + run( + host.integration.attempt.status({ + ...input, + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), + complete: (input) => + run( + host.integration.attempt.complete({ + ...input, + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), + cancel: (input) => + run( + host.integration.attempt.cancel({ + ...input, + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), }, transform: transform(host.integration), reload: () => run(host.integration.reload()), @@ -122,11 +162,53 @@ export function fromPromise(plugin: PromisePlugin) { register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, session: { - create: (input) => run(host.session.create(input)), - get: (input) => run(host.session.get(input)), - prompt: (input) => run(host.session.prompt(input)), - command: (input) => run(host.session.command(input)), - interrupt: (input) => run(host.session.interrupt(input)), + create: (input) => + run( + host.session.create( + input === undefined + ? undefined + : { + id: input.id == null ? undefined : Session.ID.make(input.id), + agent: input.agent == null ? undefined : Agent.ID.make(input.agent), + model: input.model == null ? undefined : model(input.model), + location: + input.location == null + ? undefined + : Location.Ref.make({ + directory: AbsolutePath.make(input.location.directory), + workspaceID: + input.location.workspaceID === undefined + ? undefined + : Workspace.ID.make(input.location.workspaceID), + }), + }, + ), + ), + get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })), + prompt: (input) => + run( + host.session.prompt({ + ...input, + sessionID: Session.ID.make(input.sessionID), + id: input.id == null ? undefined : SessionMessage.ID.make(input.id), + delivery: input.delivery ?? undefined, + resume: input.resume ?? undefined, + }), + ), + command: (input) => + run( + host.session.command({ + ...input, + sessionID: Session.ID.make(input.sessionID), + id: input.id == null ? undefined : SessionMessage.ID.make(input.id), + agent: input.agent == null ? undefined : Agent.ID.make(input.agent), + model: input.model == null ? undefined : model(input.model), + arguments: input.arguments ?? undefined, + delivery: input.delivery ?? undefined, + resume: input.resume ?? undefined, + }), + ), + interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })), }, } @@ -137,6 +219,39 @@ export function fromPromise(plugin: PromisePlugin) { }) } +function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) { + return Model.Ref.make({ + id: Model.ID.make(input.id), + providerID: Provider.ID.make(input.providerID), + variant: input.variant === undefined ? undefined : Model.VariantID.make(input.variant), + }) +} + +type Wire = unknown extends Value + ? JsonValue + : Value extends string | number | boolean | bigint | symbol | null | undefined + ? Value + : Value extends DateTime.DateTime + ? number + : Value extends ReadonlyArray + ? Array> + : Value extends object + ? { -readonly [Key in keyof Value]: Wire } + : Value + +function wire(value: Value): Wire +function wire(value: unknown): unknown { + if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value) + if (Array.isArray(value)) return value.map(wire) + if (typeof value !== "object" || value === null) return value + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wire(item)])) +} + +function wireEvent(value: unknown): PromiseEvent +function wireEvent(value: unknown): unknown { + return wire(value) +} + function fromPromiseTool(tool: AnyTool) { if ("jsonSchema" in tool) return Tool.make({ diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 139e6efd2d..324a937a5c 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,7 +1,115 @@ -import { Effect } from "effect" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import { Effect, Option, Schema, Semaphore, Stream } from "effect" +import { Catalog } from "../../catalog" +import { Credential } from "../../credential" +import { EventV2 } from "../../event" +import { CopilotModels } from "../../github-copilot/models" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" + +const clientID = "Ov23li8tweQw6odWQebz" +const apiVersion = "2026-06-01" +const pollingSafetyMargin = 3000 +const methodID = Integration.MethodID.make("device") + +const Device = Schema.Struct({ + verification_uri: Schema.String, + user_code: Schema.String, + device_code: Schema.String, + interval: Schema.Number, +}) +const Token = Schema.Struct({ + access_token: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + interval: Schema.optional(Schema.Number), +}) +const JsonBody = Schema.UnknownFromJsonString +const decodeBody = Schema.decodeUnknownOption(JsonBody) + +const oauth = { + integrationID: Integration.ID.make("github-copilot"), + method: { + id: methodID, + type: "oauth", + label: "Login with GitHub Copilot", + prompts: [ + { + type: "select", + key: "deploymentType", + message: "Select GitHub deployment type", + options: [ + { label: "GitHub.com", value: "github.com", hint: "Public" }, + { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" }, + ], + }, + { + type: "text", + key: "enterpriseUrl", + message: "Enter your GitHub Enterprise URL or domain", + placeholder: "company.ghe.com or https://company.ghe.com", + when: { key: "deploymentType", op: "eq", value: "enterprise" }, + }, + ], + }, + authorize: (inputs) => + Effect.gen(function* () { + const enterprise = inputs.deploymentType === "enterprise" + if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required")) + const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com" + const urls = oauthURLs(domain) + const device = yield* request(urls.device, { + method: "POST", + headers: headers(), + body: JSON.stringify({ client_id: clientID, scope: "read:user" }), + }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) + const interval = Math.max(device.interval, 1) * 1000 + + const poll = (wait: number): Effect.Effect => + request(urls.token, { + method: "POST", + headers: headers(), + body: JSON.stringify({ + client_id: clientID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }).pipe( + Effect.map(Schema.decodeUnknownSync(Token)), + Effect.flatMap((token) => { + if (token.access_token) { + return Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: token.access_token, + access: token.access_token, + expires: 0, + ...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}), + }), + ) + } + if (token.error === "authorization_pending") + return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait))) + if (token.error === "slow_down") { + const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000 + return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next))) + } + return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`)) + }), + ) + + return { + mode: "auto" as const, + url: device.verification_uri, + instructions: `Enter code: ${device.user_code}`, + callback: poll(interval), + } + }), +} satisfies IntegrationOAuthMethodRegistration function shouldUseResponses(modelID: string) { // Copilot supports Responses for GPT-5 class models, except mini variants @@ -14,19 +122,103 @@ function shouldUseResponses(modelID: string) { export const GithubCopilotPlugin = define({ id: "opencode.provider.github-copilot", effect: Effect.fn(function* (ctx) { + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + const loading = Semaphore.makeUnsafe(1) + const loaded: { + baseURL?: string + models?: Map + } = {} + + const load = Effect.fn("GithubCopilotPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active("github-copilot") + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + if (credential?.type !== "oauth") { + loaded.baseURL = undefined + loaded.models = undefined + return + } + + const enterprise = credential.metadata?.enterpriseUrl + loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined) + const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot) + const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot) + loaded.models = yield* Effect.tryPromise({ + try: () => + CopilotModels.get( + loaded.baseURL ?? baseURL(), + { + ...provider?.headers, + Authorization: `Bearer ${credential.refresh}`, + "User-Agent": `opencode/${InstallationVersion}`, + "X-GitHub-Api-Version": apiVersion, + }, + existing, + ), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to sync GitHub Copilot models", { cause }).pipe(Effect.as(undefined)), + ), + ) + }) + + yield* ctx.integration.transform((draft) => { + draft.method.update(oauth) + }) yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) + if (!item) return + if (loaded.models) { + for (const id of item.models.keys()) { + if (!loaded.models.has(ModelV2.ID.make(id))) evt.model.remove(item.provider.id, id) + } + for (const [id, model] of loaded.models) { + evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model))) + } + } else if (loaded.baseURL) { + for (const id of item.models.keys()) { + evt.model.update(item.provider.id, id, (model) => { + model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: loaded.baseURL }) + }) + } + } + if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) { + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + } }) + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/github-copilot") return + if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return + if (evt.package !== "@ai-sdk/github-copilot" && evt.package !== "@ai-sdk/anthropic") return + evt.options.fetch = copilotFetch( + typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined, + evt.options.fetch, + evt.package === "@ai-sdk/anthropic", + ) + if (evt.package === "@ai-sdk/anthropic") { + evt.options.headers = { + ...evt.options.headers, + "anthropic-beta": "interleaved-thinking-2025-05-14", + } + const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) + evt.sdk = mod.createAnthropic(evt.options) + return + } const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), @@ -52,4 +244,114 @@ export const GithubCopilotPlugin = define({ }), ) }), -}) +} satisfies PluginInternal.InternalPlugin) + +function normalizeDomain(input: string) { + return input.replace(/^https?:\/\//, "").replace(/\/$/, "") +} + +function oauthURLs(domain: string) { + return { + device: `https://${domain}/login/device/code`, + token: `https://${domain}/login/oauth/access_token`, + } +} + +function baseURL(enterprise?: string) { + return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com" +} + +function headers() { + return { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() + }, + catch: (cause) => cause, + }) +} + +type Fetch = (input: Parameters[0], init?: RequestInit) => Promise + +export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch { + const send = upstream ?? fetch + return async (input, init) => { + const requestHeaders = new Headers(init?.headers) + if (token) { + requestHeaders.delete("authorization") + requestHeaders.delete("x-api-key") + requestHeaders.set("Authorization", `Bearer ${token}`) + } + requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`) + requestHeaders.set("Openai-Intent", "conversation-edits") + requestHeaders.set("X-GitHub-Api-Version", apiVersion) + if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14") + + const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url + const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined + const metadata = requestMetadata(url, body) + requestHeaders.set("x-initiator", metadata.agent ? "agent" : "user") + if (metadata.vision) requestHeaders.set("Copilot-Vision-Request", "true") + return send(input, { ...init, headers: requestHeaders }) + } +} + +function requestMetadata(url: string, body: unknown) { + if (!record(body)) return { agent: false, vision: false } + if (Array.isArray(body.input)) { + const last = body.input.at(-1) + return { + agent: !record(last) || last.role !== "user", + vision: body.input.some( + (item) => + record(item) && + Array.isArray(item.content) && + item.content.some((part) => record(part) && part.type === "input_image"), + ), + } + } + if (!Array.isArray(body.messages)) return { agent: false, vision: false } + const last = body.messages.at(-1) + if (url.includes("completions")) { + return { + agent: !record(last) || last.role !== "user", + vision: body.messages.some( + (message) => + record(message) && + Array.isArray(message.content) && + message.content.some((part) => record(part) && part.type === "image_url"), + ), + } + } + const content = record(last) && Array.isArray(last.content) ? last.content : [] + return { + agent: + !record(last) || last.role !== "user" || !content.some((part) => record(part) && part.type !== "tool_result"), + vision: body.messages.some( + (message) => + record(message) && + Array.isArray(message.content) && + message.content.some( + (part) => + record(part) && + (part.type === "image" || + (part.type === "tool_result" && + Array.isArray(part.content) && + part.content.some((nested) => record(nested) && nested.type === "image"))), + ), + ), + } +} + +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index 3dd42a8037..56df0813dd 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -4,6 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node" import { EventV2 } from "../event" +import type { PluginV2 } from "../plugin" export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} }) @@ -20,7 +21,7 @@ export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: { */ export interface Interface { readonly register: (plugin: Plugin) => Effect.Effect - readonly all: () => readonly Plugin[] + readonly all: () => readonly PluginV2.Versioned[] } export class Service extends Context.Service()("@opencode/SdkPlugins") {} @@ -29,11 +30,12 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const plugins = new Map() + const plugins = new Map() + let revision = 0 return Service.of({ register: (plugin) => Effect.sync(() => { - plugins.set(plugin.id, plugin) + plugins.set(plugin.id, { ...plugin, version: String(++revision) }) }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), all: () => [...plugins.values()], }) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 9b056c994e..c29c6a8647 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -116,15 +116,15 @@ const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Con }) const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( - pre: readonly Plugin[], - post: readonly Plugin[], + pre: readonly PluginV2.Versioned[], + post: readonly PluginV2.Versioned[], operations: readonly Operation[], ) { const matches = (selector: string, target: string) => selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) const definitions = [...pre, ...post] const enabled = new Set(definitions.map((plugin) => plugin.id)) - const packages = new Map() + const packages = new Map() const plugins = () => [...definitions, ...packages.values()] for (const operation of operations) { @@ -178,8 +178,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract plugin.effect({ ...host, options: operation.options }), - } satisfies Plugin + } satisfies PluginV2.Versioned }) function discoverDirectory(fs: FSUtil.Interface, directory: string) { @@ -253,10 +254,11 @@ const layer = Layer.effect( // Resolve OpenCode's internal plugins with their privileged Location services. const internal = yield* PluginInternal.list() // Combine internal plugins with host-contributed SDK plugins in boot order. - const pre = [...internal.pre, ...sdk.all()] + const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()] + const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" })) const operations = yield* scan(yield* config.entries()) // Apply config operations and load enabled package plugins into one ordered generation. - const plugins = yield* resolve(pre, internal.post, operations) + const plugins = yield* resolve(pre, post, operations) // Replace the active generation in one scoped, batched activation. yield* registry.activate(plugins) applied = target diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index fe9e48e1da..3c0e7bb479 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -64,7 +64,7 @@ const layer = Layer.effect( if (source.type === "local") { materialized.set( name, - new Info({ + Info.make({ name, path: source.path, ...(source.description === undefined ? {} : { description: source.description }), @@ -88,7 +88,7 @@ const layer = Layer.effect( seen.set(target, source.branch) materialized.set( name, - new Info({ + Info.make({ name, path: AbsolutePath.make(target), ...(source.description === undefined ? {} : { description: source.description }), diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 08a40ad88e..643228e49d 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -220,8 +220,31 @@ export const createLLMEventPublisher = (events: Pick { diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index d325ceb599..2bdc478c24 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -92,6 +92,7 @@ const layer = Layer.effect( }, list: () => draft.sources as Source[], }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) const load = Effect.fn("SkillV2.load")(function* (source: Source) { diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 8ef532f488..41072e3ce3 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -2,7 +2,7 @@ export * as Snapshot from "./snapshot" import { makeLocationNode } from "./effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect" import { Config } from "./config" import { File } from "./file" import { FSUtil } from "./fs-util" @@ -91,36 +91,33 @@ const layer = Layer.effect( const git = yield* Git.Service const global = yield* Global.Service const location = yield* Location.Service - const source = yield* git.repo.discover(location.project.directory) - const worktree = source - ? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie)) - : location.project.directory - const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree))) + const lifetime = yield* Scope.Scope + // Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization. + const repositoryFiber = yield* Effect.cached( + Effect.gen(function* () { + const source = yield* git.repo.discover(location.project.directory) + if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" }) + const worktree = AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie)) + const gitDirectory = AbsolutePath.make( + path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)), + ) + const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD"))) + ? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory }) + : yield* git.repo + .create({ worktree, gitDirectory, seed: source }) + .pipe(Effect.mapError((cause) => failure("capture", cause))) + return { source, worktree, snapshotRepository } + }).pipe(Effect.forkIn(lifetime)), + ) + const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join)) - const scope = Effect.fnUntraced(function* () { + const scope = Effect.fnUntraced(function* (worktree: AbsolutePath) { const relative = path.relative(worktree, location.directory) if (relative.startsWith("..") || path.isAbsolute(relative)) return yield* new Error({ operation: "capture", message: "Location is outside the project" }) return RelativePath.make(relative.replaceAll("\\", "/") || ".") }) - const repository = Effect.fnUntraced(function* () { - if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" }) - if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD"))) - return new Git.Repository({ - worktree, - gitDirectory, - commonDirectory: gitDirectory, - }) - return yield* git.repo - .create({ - worktree, - gitDirectory, - seed: source, - }) - .pipe(Effect.mapError((cause) => failure("capture", cause))) - }) - const enabled = Effect.fnUntraced(function* () { if (location.vcs?.type !== "git") return false return Config.latest(yield* config.entries(), "snapshots") !== false @@ -129,12 +126,12 @@ const layer = Layer.effect( const capture = Effect.fn("Snapshot.capture")(function* () { if (!(yield* enabled())) return undefined return yield* Effect.gen(function* () { - const repo = yield* repository() + const repo = yield* repository return ID.make( yield* git.tree.capture({ - repository: repo, - scopes: [yield* scope()], - ignores: source, + repository: repo.snapshotRepository, + scopes: [yield* scope(repo.worktree)], + ignores: repo.source, maximumUntrackedFileBytes: 2 * 1024 * 1024, }), ) @@ -144,38 +141,46 @@ const layer = Layer.effect( }) const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) { - const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause))) - return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) } + const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause))) + return { + source: repo.source, + input: { + repository: repo.snapshotRepository, + from: Git.TreeID.make(input.from), + to: Git.TreeID.make(input.to), + }, + } }) const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) { const comparison = yield* compare("files", input) - const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause))) - if (!source) return files + const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause))) const ignored = yield* git.index - .ignored({ repository: source, paths: files }) + .ignored({ repository: comparison.source, paths: files }) .pipe(Effect.mapError((cause) => failure("files", cause))) return files.filter((file) => !ignored.has(file)) }) const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) { const comparison = yield* compare("diff", input) - const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause))) - const ignored = source - ? yield* git.index - .ignored({ repository: source, paths: files }) - .pipe(Effect.mapError((cause) => failure("diff", cause))) - : new Set() + const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause))) + const ignored = yield* git.index + .ignored({ repository: comparison.source, paths: files }) + .pipe(Effect.mapError((cause) => failure("diff", cause))) return yield* git.tree .diff({ - ...comparison, + ...comparison.input, context: input.context, paths: (input.paths ?? files).filter((file) => !ignored.has(file)), }) .pipe(Effect.mapError((cause) => failure("diff", cause))) }) - const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) { + const plan = Effect.fnUntraced(function* ( + operation: "preview" | "restore", + worktree: AbsolutePath, + input: RestoreInput, + ) { const files = new Map() for (const [file, snapshot] of input.files) { const absolute = path.resolve(worktree, file) @@ -188,19 +193,19 @@ const layer = Layer.effect( const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) { if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" }) - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause))) - const files = yield* plan("preview", input) + const repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause))) + const files = yield* plan("preview", repo.worktree, input) const current = yield* git.tree .capture({ - repository: repo, + repository: repo.snapshotRepository, scopes: Array.from(files.keys()), - ignores: source, + ignores: repo.source, maximumUntrackedFileBytes: 2 * 1024 * 1024, }) .pipe(Effect.mapError((cause) => failure("preview", cause))) return yield* git.tree .preview({ - repository: repo, + repository: repo.snapshotRepository, current, files, context: input.context, @@ -210,16 +215,16 @@ const layer = Layer.effect( const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) { if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" }) - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause))) yield* git.tree - .restore({ repository: repo, files: yield* plan("restore", input) }) + .restore({ repository: repo.snapshotRepository, files: yield* plan("restore", repo.worktree, input) }) .pipe(Effect.mapError((cause) => failure("restore", cause))) }) const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) { - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause))) yield* git.tree - .checkout({ repository: repo, tree: Git.TreeID.make(snapshot) }) + .checkout({ repository: repo.snapshotRepository, tree: Git.TreeID.make(snapshot) }) .pipe(Effect.mapError((cause) => failure("restore", cause))) }) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 84131b18a6..9b7015935a 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -12,8 +12,8 @@ import { Tool } from "./tool" export const name = "subagent" const NO_TEXT = "Subagent completed without a text response." -const BACKGROUND_STARTED = - "The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress." +const backgroundStarted = (sessionID: SessionSchema.ID) => + `The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.` export const Input = Schema.Struct({ agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }), @@ -65,6 +65,7 @@ export const Plugin = { const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* ( parentID: SessionSchema.ID, childID: SessionSchema.ID, + agent: string, description: string, state: "completed" | "error" | "cancelled", text: string, @@ -72,22 +73,32 @@ export const Plugin = { yield* runtime.session.synthetic({ sessionID: parentID, text: `\n${text}\n`, + description, + metadata: { source: "subagent", childID, agent, state }, }) }) const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* ( parentID: SessionSchema.ID, childID: SessionSchema.ID, + agent: string, description: string, ) { yield* runtime.job.wait({ id: childID }).pipe( Effect.flatMap((result) => { if (result.info?.status === "completed") - return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT) + return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT) if (result.info?.status === "error") - return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed") + return injectCompletion( + parentID, + childID, + agent, + description, + "error", + result.info.error ?? "Subagent failed", + ) if (result.info?.status === "cancelled") - return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled") + return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled") return Effect.void }), Effect.forkIn(scope, { startImmediately: true }), @@ -167,8 +178,12 @@ export const Plugin = { if (background) { yield* runtime.job.background(info.id) - yield* notifyWhenDone(context.sessionID, child.id, input.description) - return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } + yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description) + return { + sessionID: child.id, + status: "running" as const, + output: backgroundStarted(child.id), + } } const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe( @@ -179,8 +194,12 @@ export const Plugin = { ), ) if (result?.type === "backgrounded") { - yield* notifyWhenDone(context.sessionID, child.id, input.description) - return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } + yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description) + return { + sessionID: child.id, + status: "running" as const, + output: backgroundStarted(child.id), + } } if (result?.info.status === "error") return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 25030f6e53..17d887e192 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -2,8 +2,6 @@ import { describe, expect } from "bun:test" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Event } from "@opencode-ai/schema/event" -import { EventManifest } from "@opencode-ai/schema/event-manifest" -import { McpEvent } from "@opencode-ai/schema/mcp-event" import { Session } from "@opencode-ai/schema/session" import { SessionEvent } from "@opencode-ai/schema/session-event" import { SessionV1 } from "@opencode-ai/schema/session-v1" @@ -363,51 +361,6 @@ describe("EventV2", () => { }), ) - it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const consuming = yield* Deferred.make() - const release = yield* Deferred.make() - const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 }) - const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 }) - const slow = yield* slowStream.pipe( - Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))), - Effect.forkScoped, - ) - const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) - - yield* events.publish(Message, { text: "one" }) - yield* Deferred.await(consuming) - yield* events.publish(Message, { text: "two" }) - yield* events.publish(Message, { text: "overflow" }) - const last = yield* events.publish(Message, { text: "still delivered" }) - yield* Deferred.succeed(release, undefined) - - const slowExit = yield* Fiber.await(slow) - expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError) - expect(Array.from(yield* Fiber.join(fast))).toEqual([ - expect.objectContaining({ data: { text: "one" } }), - expect.objectContaining({ data: { text: "two" } }), - expect.objectContaining({ data: { text: "overflow" } }), - last, - ]) - }), - ) - - it.effect("filters internal events before they enter a bounded server stream", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer }) - const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) - - yield* events.publish(McpEvent.ToolsChanged, { server: "one" }) - yield* events.publish(McpEvent.ToolsChanged, { server: "two" }) - const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" }) - - expect(Array.from(yield* Fiber.join(received))).toEqual([published]) - }), - ) - it.effect("preserves observer interruption", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index 7441ff83db..d4d80fd732 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -13,6 +13,26 @@ import { testEffect } from "./lib/effect" const it = testEffect(LayerNode.compile(Git.node)) describe("Git", () => { + it.live("discovers repository metadata without a work tree", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(async () => { + await initRepo(root.path) + await $`git config core.bare true`.cwd(root.path).quiet() + }) + const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path))) + const git = yield* Git.Service + const repository = yield* git.repo.discover(directory) + + expect(repository?.worktree).toBe(directory) + expect(repository?.gitDirectory).toBe(AbsolutePath.make(path.join(directory, ".git"))) + expect(repository?.commonDirectory).toBe(repository?.gitDirectory) + }), + ) + it.live("clones a remote and reads checkout metadata", () => withRemote((fixture) => Effect.gen(function* () { diff --git a/packages/core/test/github-copilot/models.test.ts b/packages/core/test/github-copilot/models.test.ts new file mode 100644 index 0000000000..31d8f710cd --- /dev/null +++ b/packages/core/test/github-copilot/models.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test" +import { CopilotModels } from "@opencode-ai/core/github-copilot/models" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +test("defensively syncs advertised Copilot models", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json({ + data: [ + { + model_picker_enabled: true, + id: "gpt-5", + name: "GPT-5 remote", + version: "gpt-5-2026-06-01", + supported_endpoints: ["/responses"], + billing: { + token_prices: { + batch_size: 0, + default: { input_price: 10, output_price: 20, cache_price: 5 }, + }, + }, + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 200000, + max_output_tokens: 16384, + max_prompt_tokens: 180000, + }, + supports: { tool_calls: true, reasoning_effort: ["low", "high"] }, + }, + }, + { + model_picker_enabled: false, + id: "utility", + name: "Utility", + version: "utility-2026-06-01", + capabilities: { + family: "utility", + limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 }, + supports: { tool_calls: false }, + }, + }, + { model_picker_enabled: true, id: "incomplete" }, + ], + }), + }) + + try { + const existing = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")), + modelID: ModelV2.ID.make("gpt-5"), + name: "GPT-5 local", + }) + const stale = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")), + modelID: ModelV2.ID.make("stale"), + }) + const models = await CopilotModels.get(server.url.origin, {}, [existing, stale]) + const model = models.get(ModelV2.ID.make("gpt-5")) + + expect(model?.name).toBe("GPT-5 local") + expect(model?.settings).toMatchObject({ baseURL: server.url.origin, endpoint: "responses" }) + expect(model?.cost[0]).toMatchObject({ input: 0, output: 0, cache: { read: 0, write: 0 } }) + expect(model?.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("low"), + ModelV2.VariantID.make("high"), + ]) + expect(models.get(ModelV2.ID.make("utility"))?.enabled).toBe(false) + expect(models.has(ModelV2.ID.make("stale"))).toBe(false) + expect(models.has(ModelV2.ID.make("incomplete"))).toBe(false) + } finally { + await server.stop(true) + } +}) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index dc20303f00..0cbf46bb44 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -40,7 +40,7 @@ describe("Integration", () => { .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(scope)) expect(yield* integrations.get(openai)).toEqual( - new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }), + Integration.Info.make({ id: openai, name: "OpenAI", methods: [], connections: [] }), ) yield* Scope.close(scope, Exit.void) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index d4ae3b7cf5..ec84b3b8ad 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -249,6 +249,36 @@ describe("LocationServiceMap", () => { ), ) + itWithSdk.live("does not reload plugins when config updates leave plugin operations unchanged", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const activations = { count: 0 } + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "unchanged-config-plugin", + effect: () => Effect.sync(() => ++activations.count).pipe(Effect.asVoid), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)) + expect(activations.count).toBe(1) + + yield* EventV2.Service.use((events) => events.publish(Config.Event.Updated, {})).pipe(Effect.provide(context)) + yield* Effect.sleep("200 millis") + + expect(activations.count).toBe(1) + }), + ), + ), + ) + itWithSdk.live("keeps flush open while later hot reload runs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -706,7 +736,7 @@ describe("LocationServiceMap", () => { }) .pipe(Effect.asVoid), }) - yield* plugins.activate([reviewer]) + yield* plugins.activate([{ ...reviewer, version: "1" }]) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index a21dc25479..8c2db479e5 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -18,6 +18,8 @@ const it = testEffect(PluginTestLayer) class Secret extends Context.Service()("@opencode/test/PluginSecret") {} +const versioned = (plugin: EffectPlugin.Plugin, version = "1") => ({ ...plugin, version }) + describe("PluginV2", () => { it.live("exposes public events through the plugin context", () => Effect.gen(function* () { @@ -37,15 +39,18 @@ describe("PluginV2", () => { }), ) - it.effect("replaces plugins by ID", () => + it.effect("replaces plugins by ID and version", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const agents = yield* AgentV2.Service const events = yield* EventV2.Service let description = "first" - const updated = yield* events - .subscribe(Plugin.Event.Updated) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) + let updates = 0 + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + if (event.type === Plugin.Event.Updated.type) updates++ + }), + ) const managed = () => EffectPlugin.define({ @@ -60,17 +65,23 @@ describe("PluginV2", () => { .pipe(Effect.asVoid), }) - yield* plugins.activate([managed()]) + yield* plugins.activate([versioned(managed(), "1")]) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") description = "second" - yield* plugins.activate([managed()]) + yield* plugins.activate([versioned(managed(), "2")]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") + + description = "third" + yield* plugins.activate([versioned(managed(), "2")]) + expect(updates).toBe(2) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - expect(yield* Fiber.join(updated)).toHaveLength(2) yield* plugins.activate([]) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() + expect(updates).toBe(3) + yield* unsubscribe }), ) @@ -79,12 +90,12 @@ describe("PluginV2", () => { const plugins = yield* PluginV2.Service const active = Plugin.ID.make("active") const duplicate = "duplicate" - yield* plugins.activate([{ id: active, effect: () => Effect.void }]) + yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }]) const result = yield* plugins .activate([ - { id: duplicate, effect: () => Effect.void }, - { id: duplicate, effect: () => Effect.void }, + { id: duplicate, version: "1", effect: () => Effect.void }, + { id: duplicate, version: "1", effect: () => Effect.void }, ]) .pipe(Effect.exit) @@ -117,12 +128,12 @@ describe("PluginV2", () => { }, }) - yield* plugins.activate([good, bad]) + yield* plugins.activate([versioned(good), versioned(bad)]) expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }]) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded") fail = false - yield* plugins.activate([good, bad]) + yield* plugins.activate([versioned(good), versioned(bad, "2")]) expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }]) }), ) @@ -155,8 +166,8 @@ describe("PluginV2", () => { }), }) - yield* plugins.activate([previous]) - yield* plugins.activate([replacement]) + yield* plugins.activate([versioned(previous)]) + yield* plugins.activate([versioned(replacement, "2")]) expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }]) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("previous") @@ -187,8 +198,8 @@ describe("PluginV2", () => { effect: () => Effect.die(new Error("replacement failed")), }) - yield* plugins.activate([previous]) - yield* plugins.activate([replacement]) + yield* plugins.activate([versioned(previous)]) + yield* plugins.activate([versioned(replacement, "2")]) expect(yield* plugins.list()).toEqual([]) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() @@ -202,6 +213,7 @@ describe("PluginV2", () => { yield* plugins.activate( ["first", "second"].map((id) => ({ id, + version: "1", effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))), })), ) @@ -225,7 +237,7 @@ describe("PluginV2", () => { ), }) - yield* plugins.activate([plugin]).pipe(Effect.provideService(Secret, "secret")) + yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret")) expect(visible).toBe(false) }), @@ -253,7 +265,7 @@ describe("PluginV2", () => { .pipe(Effect.orDie), }) - yield* plugins.activate([plugin]) + yield* plugins.activate([versioned(plugin)]) expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool") yield* plugins.activate([]) @@ -284,7 +296,7 @@ describe("PluginV2", () => { .pipe(Effect.orDie), }) - yield* plugins.activate([plugin]) + yield* plugins.activate([versioned(plugin)]) expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([ "plain", @@ -343,7 +355,7 @@ describe("PluginV2", () => { }), }) - yield* plugins.activate([plugin]) + yield* plugins.activate([versioned(plugin)]) const materialized = yield* registry.materialize() const settlement = yield* materialized.settle({ diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index bb8e460d73..096d73f5bf 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -214,7 +214,7 @@ describe("ModelsDevPlugin", () => { }), ) expect(yield* integrations.list()).toEqual([ - new Integration.Info({ + Integration.Info.make({ id: Integration.ID.make("acme"), name: "Acme", methods: [ diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b1e6c9c772..b850d9ac73 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -5,8 +5,9 @@ import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Integration } from "@opencode-ai/core/integration" import type { LanguageModelV3 } from "@ai-sdk/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -39,6 +40,46 @@ function fakeSelectorSdk(calls: string[]) { } describe("GithubCopilotPlugin", () => { + it.effect("registers GitHub Copilot device OAuth", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({ + id: Integration.MethodID.make("device"), + type: "oauth", + label: "Login with GitHub Copilot", + prompts: expect.any(Array), + }) + }), + ) + + it.live("adds Copilot authentication and request metadata headers", () => + Effect.gen(function* () { + const requests: Headers[] = [] + const send = copilotFetch( + "token", + async (_input: Parameters[0], init?: RequestInit) => { + requests.push(new Headers(init?.headers)) + return Response.json({ ok: true }) + }, + false, + ) + yield* Effect.promise(() => + send("https://api.githubcopilot.com/chat/completions", { + method: "POST", + headers: { "x-api-key": "old" }, + body: JSON.stringify({ + messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] }], + }), + }), + ) + expect(requests[0]?.get("authorization")).toBe("Bearer token") + expect(requests[0]?.has("x-api-key")).toBe(false) + expect(requests[0]?.get("x-initiator")).toBe("user") + expect(requests[0]?.get("copilot-vision-request")).toBe("true") + expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01") + }), + ) + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 17d412f902..cdd3b36898 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -26,7 +26,7 @@ describe("ReferenceGuidance", () => { Layer.mock(Reference.Service, { list: () => Effect.succeed([ - new Reference.Info({ + Reference.Info.make({ name: "docs", path: AbsolutePath.make("/docs"), description: "Use for product documentation", @@ -62,7 +62,7 @@ describe("ReferenceGuidance", () => { Layer.mock(Reference.Service, { list: () => Effect.succeed([ - new Reference.Info({ + Reference.Info.make({ name: "docs", path: AbsolutePath.make("/docs"), source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }), @@ -76,7 +76,7 @@ describe("ReferenceGuidance", () => { it.effect("announces added and removed references as deltas", () => { const reference = (name: string, description: string) => - new Reference.Info({ + Reference.Info.make({ name, path: AbsolutePath.make(`/${name}`), description, diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index db61232310..a5a656c8bf 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -29,7 +29,7 @@ describe("Reference", () => { yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope)) expect(yield* references.list()).toEqual([ - new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }), + Reference.Info.make({ name: "docs", path, description: "Use for API documentation", hidden: true, source }), ]) yield* Scope.close(scope, Exit.void) @@ -45,7 +45,7 @@ describe("Reference", () => { yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ - new Reference.Info({ + Reference.Info.make({ name: "sdk", path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), source, @@ -66,7 +66,7 @@ describe("Reference", () => { yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ - new Reference.Info({ + Reference.Info.make({ name: "sdk", path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), description: "Use for SDK implementation details", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 30499aca1b..6b7316c6ed 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -6,6 +6,7 @@ import { Model, ToolFailure, TransportReason, + InvalidProviderOutputReason, InvalidRequestReason, RateLimitReason, type LLMClientShape, @@ -716,7 +717,18 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => type: "assistant", finish: "error", error: { type: "provider.transport", message: "Provider unavailable" }, - content: [fixture.expectedContent], + content: [ + kind === "tool input" + ? { + type: "tool", + id: fragmentID(kind, "partial"), + state: { + status: "error", + error: { type: "provider.transport", message: "Provider unavailable" }, + }, + } + : fixture.expectedContent, + ], }, ]) expect(requests).toHaveLength(1) @@ -3876,6 +3888,45 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("settles malformed streamed tool input before the provider failure", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Call a malformed tool") + const failure = new LLMError({ + module: "test", + method: "stream", + reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }), + }) + responseStream = Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), + LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }), + ]).pipe(Stream.concat(Stream.fail(failure))) + + expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + const assistant = requireAssistant(yield* session.context(sessionID)) + + response = reply.stop() + yield* admit(session, "Continue") + yield* session.resume(sessionID) + + expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([ + { type: "session.step.started.1" }, + { + type: "session.tool.failed.1", + data: { + callID: "call-malformed", + error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" }, + }, + }, + { + type: "session.step.failed.1", + data: { error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" } }, + }, + ]) + }), + ) + it.effect("does not continue automatically after a provider error follows a local tool call", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 1b8d846e69..428dfaecd0 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -54,6 +54,23 @@ function waitForSkillUpdate() { } describe("SkillV2", () => { + it.live("publishes updates when skill sources change", () => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + + yield* Effect.acquireUseRelease( + waitForSkillUpdate(), + ({ deferred }) => + skill + .transform((editor) => + editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }), + ) + .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), + ({ fiber }) => Fiber.interrupt(fiber), + ) + }), + ) + it.live("registers sources and resolves later source precedence", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 3bc0f34101..19966e9d29 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -2,8 +2,9 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Git } from "@opencode-ai/core/git" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" @@ -13,6 +14,80 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" describe("Snapshot", () => { + testEffect(Layer.empty).live("keeps lazy repository discovery after the first caller is interrupted", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "one\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) + + const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node))) + const location = yield* Location.Service.pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + const started = yield* Deferred.make() + const release = yield* Deferred.make() + let discoveries = 0 + let creations = 0 + const instrumented = Git.Service.of({ + ...git, + repo: { + ...git.repo, + discover: (input) => { + discoveries++ + return git.repo.discover(input) + }, + create: (input) => + Effect.gen(function* () { + creations++ + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return yield* git.repo.create(input) + }), + }, + }) + const layer = AppNodeBuilder.build(Snapshot.node, [ + [Location.node, Layer.succeed(Location.Service, location)], + [Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })], + [Git.node, Layer.succeed(Git.Service, instrumented)], + ]) + + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(discoveries).toBe(0) + + const interrupted = yield* snapshot.capture().pipe(Effect.forkChild) + yield* Deferred.await(started) + expect(discoveries).toBe(1) + expect(creations).toBe(1) + yield* Fiber.interrupt(interrupted) + + const capture = yield* snapshot.capture().pipe(Effect.forkChild) + expect(discoveries).toBe(1) + expect(creations).toBe(1) + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(capture)).toBeDefined() + expect(discoveries).toBe(1) + expect(creations).toBe(1) + }).pipe(Effect.provide(layer)) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 56e3587162..e0250065c5 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -281,10 +281,22 @@ describe("SubagentTool", () => { }, }) const childID = outputSessionID(settled.output?.structured) - expect(settled.output?.structured).toMatchObject({ status: "running" }) + expect(settled.output?.structured).toMatchObject({ + status: "running", + output: expect.stringContaining(`id: ${childID}`), + }) const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.input.data.text).toContain(` message.type === "synthetic") diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 82d894cb23..88204e771a 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -309,7 +309,7 @@ export function emitPromise( { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes, options?.mutableOutputs ?? false) }, { path: "client-error.ts", - content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" | "SseEventTooLarge"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, }, { path: "client.ts", @@ -580,6 +580,17 @@ function renderPromiseTypes( types.set(projected.ast, type) return type } + const outputMarkers = new Map() + const outputSchemas: Array = [] + const outputTypeOf = (schema: Schema.Top) => { + const projected = Schema.toEncoded(schema) + const cached = outputMarkers.get(projected.ast) + if (cached !== undefined) return cached + const marker = `__PROMISE_TYPE_${outputSchemas.length}__` + outputSchemas.push(projected) + outputMarkers.set(projected.ast, marker) + return marker + } const errors = new Map( groups.flatMap((group) => group.endpoints.flatMap((endpoint) => @@ -618,26 +629,42 @@ function renderPromiseTypes( const successSchema = endpoint.successes[0] const success = outputTypes?.[clientOperationKey(group, endpoint)]?.name ?? - typeOf( + outputTypeOf( isStreamSchema(successSchema) && successSchema._tag === "StreamSse" ? successSchema.sseMode === "data" ? streamEncodedDataSchema(successSchema) : successSchema.events : successSchema, ) - const output = mutableOutputs ? mutableType(success) : success return [ ...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), - `export type ${prefix}Output = ${endpoint.unwrapData ? `(${output})["data"]` : output}`, + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, ] }), ) .join("\n\n") - const json = operations.includes("JsonValue") + const reservedNames = new Set([ + "ClientError", + "JsonValue", + ...errors.keys(), + ...groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.clientPath) + return [`${prefix}Input`, `${prefix}Output`] + }), + ), + ...Object.values(outputTypes ?? {}).map((output) => output.name), + ]) + const rendered = structuralTypes(outputSchemas, mutableOutputs, reservedNames) + const resolve = (source: string) => + rendered.types.reduce((result, type, index) => result.replaceAll(`__PROMISE_TYPE_${index}__`, type), source) + const resolvedErrors = errorTypes.map(resolve) + const resolvedOperations = resolve(operations) + const json = [...rendered.definitions, ...resolvedErrors, resolvedOperations].some((type) => type.includes("JsonValue")) ? `export type JsonValue = null | boolean | number | string | ${mutableOutputs ? "Array | { [key: string]: JsonValue }" : "ReadonlyArray | { readonly [key: string]: JsonValue }"}` : "" const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))] - return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n") + return [...imports, json, ...rendered.definitions, ...resolvedErrors, resolvedOperations].filter(Boolean).join("\n\n") } function mutableType(type: string) { @@ -695,7 +722,7 @@ function renderPromiseClient(groups: ReadonlyArray) { if (group.endpoints[0]?.topLevel) return fields return `${JSON.stringify(group.identifier)}: { ${fields} }` }) - return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined) return\n if (value === null) {\n params.append(key, "null")\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: RequestInit["headers"]\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: RequestInit["headers"]\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nconst maxSseEventBytes = 16 * 1024 * 1024\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined) return\n if (value === null) {\n params.append(key, "null")\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` } function promiseTypePrefix(group: string, path: ReadonlyArray) { @@ -770,6 +797,52 @@ function identifierPart(value: string) { .join("") } +function structuralTypes(schemas: ReadonlyArray, mutable: boolean, reservedNames: ReadonlySet) { + if (schemas.length === 0) return { types: [], definitions: [] } + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs(schemas.map((schema) => schema.ast) as [SchemaAST.AST, ...Array]), + ) + if ( + document.artifacts.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + const names = new Map() + const usedNames = new Set(reservedNames) + for (const reference of document.references.nonRecursives) { + const seed = identifierPart(reference.$ref) + const name = uniqueTypeName(seed, usedNames) + names.set(reference.$ref, name) + usedNames.add(name) + } + const render = (type: string) => { + for (const [reference, name] of names) { + const pattern = `(?/g, "") + .replaceAll("Schema.Json", "JsonValue") + .replaceAll(/(? render(code.Type)), + definitions: document.references.nonRecursives.map( + (reference) => `export type ${names.get(reference.$ref)} = ${render(reference.code.Type)}`, + ), + } +} + +function uniqueTypeName(seed: string, used: ReadonlySet, suffix = 1): string { + const name = suffix === 1 ? seed : `${seed}${suffix}` + return used.has(name) ? uniqueTypeName(seed, used, suffix + 1) : name +} + function structuralType(schema: Schema.Top) { const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) if ( diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 7679c61fa8..1404f4884d 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -496,7 +496,7 @@ describe("HttpApiCodegen.generate", () => { expect(types).not.toContain("Brand") }) - test("inlines non-recursive references in Promise wire types", () => { + test("retains non-recursive references in Promise wire types", () => { const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) const output = emitPromise( compileContract( @@ -508,9 +508,9 @@ describe("HttpApiCodegen.generate", () => { ), ) - expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( - 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]', - ) + const types = output.files.find((file) => file.path === "types.ts")?.content + expect(types).toContain('export type Referenced = { readonly "value": string }') + expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced })["data"]') }) test("emits mutable Promise outputs without restricting inputs", () => { @@ -531,7 +531,7 @@ describe("HttpApiCodegen.generate", () => { expect(types).toContain('export type SessionCreateOutput = ({ "data": Array<{ "values": Array }> })["data"]') }) - test("expands Promise references only at identifier boundaries", () => { + test("retains distinct Promise references at identifier boundaries", () => { const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({ identifier: "Session", }) @@ -546,9 +546,24 @@ describe("HttpApiCodegen.generate", () => { ), ) - expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( - 'readonly "session": ({ readonly "name": "Session", readonly "id": string })', + const types = output.files.find((file) => file.path === "types.ts")?.content + expect(types).toContain('export type Session = { readonly "name": "Session", readonly "id": string }') + expect(types).toContain("export type SessionID = string") + expect(types).toContain('readonly "session": Session, readonly "sessionID": SessionID') + }) + + test("disambiguates flattened Promise reference names", () => { + const First = Schema.String.annotate({ identifier: "ExampleName" }) + const Second = Schema.String.annotate({ identifier: "Example_Name" }) + const output = emitPromise( + compileContract( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ first: First, second: Second }) })), + ), ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain("export type ExampleName = string") + expect(types).toContain("export type ExampleName2 = string") }) test("emits Effect Json schemas as standalone Promise types", () => { @@ -1082,6 +1097,27 @@ describe("HttpApiCodegen.generate", () => { expect(output.operations[0]?.success).toBe("stream") }) + test("emits opaque Promise SSE fields as any", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ + data: Schema.Struct({ + metadata: Schema.Record(Schema.String, Schema.Unknown), + label: Schema.Literal("unknown"), + }), + }), + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "metadata": { readonly [x: string]: any }') + expect(types).toContain('readonly "label": "unknown"') + }) + test("preserves annotated stream response statuses", () => { const output = compile( api( diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 0aa4e2068e..769d5c6c59 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,7 +2,7 @@ import { Config } from "@/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { TuiConfig } from "@opencode-ai/tui/config" +import { TuiConfig } from "@opencode-ai/tui/config/v1" import { Schema } from "effect" type JsonSchema = Record diff --git a/packages/opencode/src/config/tui-host-attention.ts b/packages/opencode/src/config/tui-host-attention.ts index d74ce888b9..9ecb59e66a 100644 --- a/packages/opencode/src/config/tui-host-attention.ts +++ b/packages/opencode/src/config/tui-host-attention.ts @@ -1,4 +1,4 @@ -import { TuiConfig } from "@opencode-ai/tui/config" +import { TuiConfig } from "@opencode-ai/tui/config/v1" import { isRecord } from "@opencode-ai/tui/util/record" import { Filesystem } from "@/util/filesystem" import { Schema } from "effect" diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts index 6ca254311e..63dd36ec62 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/config/tui-migrate.ts @@ -2,7 +2,7 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" import { Option, Schema } from "effect" -import { TuiConfig } from "@opencode-ai/tui/config" +import { TuiConfig } from "@opencode-ai/tui/config/v1" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 8f503751f3..2c69c91bb8 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -15,14 +15,14 @@ import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" import { CurrentWorkingDirectory } from "./tui-cwd" import { ConfigPlugin } from "@/config/plugin" -import { TuiKeybind } from "@opencode-ai/tui/config/keybind" +import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { FormatError, FormatUnknownError } from "@/cli/error" -import { TuiConfig } from "@opencode-ai/tui/config" +import { TuiConfig } from "@opencode-ai/tui/config/v1" export const Info = TuiConfig.Info export type Info = TuiConfig.Info diff --git a/packages/opencode/test/cli/cmd/tui/attention.test.ts b/packages/opencode/test/cli/cmd/tui/attention.test.ts index c8644385ab..cdad38505b 100644 --- a/packages/opencode/test/cli/cmd/tui/attention.test.ts +++ b/packages/opencode/test/cli/cmd/tui/attention.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { AudioPlayOptions, AudioSound } from "@opentui/core" import { createTuiAttention } from "@opencode-ai/tui/attention" -import type { TuiConfig } from "@opencode-ai/tui/config" +import type { TuiConfig } from "@opencode-ai/tui/config/v1" type FocusEvent = "focus" | "blur" diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index f642c47c08..d1eb963b42 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" -import type { Resolved } from "@opencode-ai/tui/config" +import type { Resolved } from "@opencode-ai/tui/config/v1" import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 4ff9bbb943..eb8181070f 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,8 +1,8 @@ import { spyOn } from "bun:test" import path from "path" -import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config" +import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config/v1" import { TuiConfig } from "../../src/config/tui" -import { TuiKeybind } from "@opencode-ai/tui/config/keybind" +import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind" type PluginSpec = string | [string, Record] type PluginOrigin = { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index bc94f1024c..65b6bcb9d6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -15,6 +15,7 @@ "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/*": "./src/v2/effect/*.ts", + "./v2/tui/*": "./src/v2/tui/*.ts", "./v2": "./src/v2/promise/index.ts", "./v2/*": "./src/v2/promise/*.ts" }, diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts new file mode 100644 index 0000000000..5989bb34dc --- /dev/null +++ b/packages/plugin/src/v2/tui/context.ts @@ -0,0 +1,112 @@ +import type { + AgentInfo, + CommandInfo, + FormInfo, + IntegrationInfo, + LocationRef, + McpResource, + McpServer, + ModelInfo, + OpenCodeClient, + OpenCodeEvent, + PermissionSavedInfo, + PermissionV2Request, + ProviderV2Info, + ReferenceInfo, + SessionInfo, + SessionMessageInfo, + SessionPendingInfo, + ShellInfo, + SkillInfo, +} from "@opencode-ai/client" +import type { JSX } from "@opentui/solid" + +interface LocationCollection { + list(location?: LocationRef): Value[] | undefined + refresh(location?: LocationRef): Promise +} + +export interface Data { + readonly on: ( + type: Type, + handler: (event: Extract) => void, + ) => () => void + readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void + readonly session: { + list(): SessionInfo[] + get(sessionID: string): SessionInfo | undefined + root(sessionID: string): string + family(sessionID: string): string[] + cost(sessionID: string): number + status(sessionID: string): "idle" | "running" + readonly pending: { + list(sessionID: string): SessionPendingInfo[] + refresh(sessionID: string): Promise + } + refresh(sessionID: string): Promise + readonly message: { + list(sessionID: string): SessionMessageInfo[] + get(sessionID: string, messageID: string): SessionMessageInfo | undefined + refresh(sessionID: string): Promise + } + readonly permission: { + list(sessionID: string): PermissionV2Request[] | undefined + refresh(sessionID: string): Promise + } + readonly form: { + list(sessionID: string, location?: LocationRef): Array | undefined + refresh(sessionID: string, location?: LocationRef): Promise + } + } + readonly project: { + readonly permission: { + list(projectID: string): PermissionSavedInfo[] | undefined + refresh(projectID: string): Promise + } + } + readonly shell: { + list(location?: LocationRef): ShellInfo[] + get(id: string): ShellInfo | undefined + refresh(location?: LocationRef): Promise + } + readonly location: { + default(): LocationRef + refresh(location?: LocationRef): Promise + readonly agent: LocationCollection + readonly command: LocationCollection + readonly integration: LocationCollection + readonly mcp: { + readonly server: LocationCollection + readonly resource: LocationCollection + } + readonly model: LocationCollection + readonly provider: LocationCollection + readonly reference: LocationCollection + readonly skill: LocationCollection + } +} + +export interface RouteDefinition { + readonly name: string + readonly render: (input: { readonly params: any }) => JSX.Element +} + +export interface Route { + register(definition: RouteDefinition): () => void + navigate(input: { readonly name: string; readonly params?: any }): void + current(): { + readonly name: string + readonly params: any + } +} + +export interface UI { + readonly route: Route +} + +export interface Context { + readonly options: Record + readonly client: OpenCodeClient + readonly data: Data + readonly ui: UI +} diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 27201783cd..e89525c1de 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -54,6 +54,7 @@ export const groupNames = { "server.event": "event", "server.pty": "pty", "server.shell": "shell", + "server.mcp": "mcp", "server.question": "question", "server.reference": "reference", "server.project": "project", diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 329bf8207f..6caa559710 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -92,12 +92,13 @@ export const Ref = Schema.Struct({ name: Schema.String, }).annotate({ identifier: "Integration.Ref" }) -export class Info extends Schema.Class("Integration.Info")({ +export const Info = Schema.Struct({ id: ID, name: Schema.String, methods: Schema.Array(Method), connections: Schema.Array(Connection.Info), -}) {} +}).annotate({ identifier: "Integration.Info" }) +export interface Info extends Schema.Schema.Type {} export const AttemptID = Schema.String.pipe( Schema.brand("Integration.AttemptID"), diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts index 9dd277f62b..0b4311a93b 100644 --- a/packages/schema/src/reference.ts +++ b/packages/schema/src/reference.ts @@ -30,10 +30,11 @@ export const Source = Schema.Union([LocalSource, GitSource]) .annotate({ identifier: "Reference.Source" }) export type Source = typeof Source.Type -export class Info extends Schema.Class("Reference.Info")({ +export const Info = Schema.Struct({ name: Schema.String, path: AbsolutePath, description: Schema.String.pipe(optional), hidden: Schema.Boolean.pipe(optional), source: Source, -}) {} +}).annotate({ identifier: "Reference.Info" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index 59ac25decf..bcd09cd901 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -23,8 +23,8 @@ export const Status = Schema.Literals(["running", "exited", "timeout", "killed"] export type Status = typeof Status.Type export const Time = Schema.Struct({ - started: Schema.Number, - completed: optional(Schema.Number), + started: Schema.Finite, + completed: optional(Schema.Finite), }) export interface Time extends Schema.Schema.Type {} @@ -42,15 +42,15 @@ export const Info = Schema.Struct({ // Absolute path of the file capturing combined stdout/stderr. Page through it via `output`. file: Schema.String, pid: optional(NonNegativeInt), - exit: optional(Schema.Number), + exit: optional(Schema.Finite), // Always present; defaults to an empty object when the creator supplies no metadata. metadata: Metadata, time: Time, -}).annotate({ identifier: "Shell" }) +}).annotate({ identifier: "Shell.Info" }) export interface Info extends Schema.Schema.Type {} const Created = ephemeral({ type: "shell.created", schema: { info: Info } }) -const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } }) +const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Finite), status: Status } }) const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } }) export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) } diff --git a/packages/server/package.json b/packages/server/package.json index 095eb70654..17507ea5b4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -9,6 +9,7 @@ "./*": "./src/*.ts" }, "scripts": { + "test": "bun test --only-failures", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/packages/server/src/event-feed.ts b/packages/server/src/event-feed.ts new file mode 100644 index 0000000000..7b668df5b4 --- /dev/null +++ b/packages/server/src/event-feed.ts @@ -0,0 +1,90 @@ +export * as EventFeed from "./event-feed" + +import { EventV2 } from "@opencode-ai/core/event" +import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect" + +export const SubscriberCapacity = 4_096 + +export class SubscriberOverflowError extends Schema.TaggedErrorClass()( + "EventFeed.SubscriberOverflow", + { capacity: Schema.Int }, +) {} + +export class EncodingError extends Schema.TaggedErrorClass()("EventFeed.EncodingError", { + eventID: EventV2.ID, + eventType: Schema.String, + cause: Schema.Defect(), +}) {} + +export type Error = SubscriberOverflowError | EncodingError + +export interface Interface { + readonly subscribe: Effect.Effect, never, Scope.Scope> +} + +export class Service extends Context.Service()("@opencode/server/EventFeed") {} + +const encode = Schema.encodeUnknownSync(OpenCodeEvent) + +export function frame(event: OpenCodeEvent) { + return `data: ${JSON.stringify(encode(event))}\n\n` +} + +export const make = Effect.fn("EventFeed.make")(function* ( + observe: (subscriber: EventV2.Subscriber) => Effect.Effect, + options?: { readonly capacity?: number; readonly encode?: (event: OpenCodeEvent) => string }, +) { + const capacity = options?.capacity ?? SubscriberCapacity + const render = options?.encode ?? frame + const subscribers = new Set>() + + const fail = (error: Error) => + Effect.sync(() => { + const current = Array.from(subscribers) + subscribers.clear() + for (const subscriber of current) Queue.failCauseUnsafe(subscriber, Cause.fail(error)) + }) + + const publish = Effect.fnUntraced(function* (event: EventV2.Payload) { + if (!isOpenCodeEvent(event)) return + if (subscribers.size === 0) return + const encoded = yield* Effect.try({ + try: () => render(event), + catch: (cause) => new EncodingError({ eventID: event.id, eventType: event.type, cause }), + }).pipe( + Effect.catch((error) => + Effect.logError("Failed to encode public event", { + eventID: error.eventID, + eventType: error.eventType, + cause: error.cause, + }).pipe(Effect.andThen(fail(error)), Effect.as(undefined)), + ), + ) + if (encoded === undefined) return + for (const subscriber of subscribers) { + if (Queue.offerUnsafe(subscriber, encoded)) continue + subscribers.delete(subscriber) + Queue.failCauseUnsafe(subscriber, Cause.fail(new SubscriberOverflowError({ capacity }))) + } + }) + + const unsubscribe = yield* observe(publish) + yield* Effect.addFinalizer(() => unsubscribe) + + return Service.of({ + subscribe: Effect.acquireRelease( + Queue.dropping(capacity).pipe(Effect.tap((queue) => Effect.sync(() => subscribers.add(queue)))), + (queue) => + Effect.sync(() => subscribers.delete(queue)).pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid), + ).pipe(Effect.map(Stream.fromQueue)), + }) +}) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + return yield* make(events.listen) + }), +) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 5dfbefe11c..4b0b4acaa2 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -26,6 +26,7 @@ import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" import { ProjectCopyHandler } from "./handlers/project-copy" import { VcsHandler } from "./handlers/vcs" +import { EventFeed } from "./event-feed" export const handlers = Layer.mergeAll( HealthHandler, @@ -48,7 +49,7 @@ export const handlers = Layer.mergeAll( FileSystemHandler, CommandHandler, SkillHandler, - EventHandler, + EventHandler.pipe(Layer.provide(EventFeed.layer)), PtyHandler, ShellHandler, QuestionHandler, diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 524746acbf..feeba74288 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,44 +1,23 @@ import { EventV2 } from "@opencode-ai/core/event" -import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event" -import { Effect, Schema, Stream } from "effect" -import { Sse } from "effect/unstable/encoding" +import { Effect, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" - -// Session execution emits dense event bursts; allow healthy SSE clients enough -// time to absorb one without weakening the bounded slow-subscriber failure. -const subscriberCapacity = 4_096 - -function eventData(data: unknown): Sse.Event { - return { - _tag: "Event", - event: "message", - id: undefined, - data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)), - } -} +import { EventFeed } from "../event-feed" export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) => Effect.gen(function* () { - const events = yield* EventV2.Service + const feed = yield* EventFeed.Service return handlers.handleRaw("event.subscribe", () => Effect.gen(function* () { const connected = { id: EventV2.ID.create(), type: "server.connected", data: {}, - } + } as const const output = Stream.unwrap( - Effect.gen(function* () { - // Acquiring the bounded stream installs its listener before readiness is observable. - const live = yield* EventV2.liveBounded(events, { - capacity: subscriberCapacity, - accept: isOpenCodeEvent, - }) - return Stream.make(connected).pipe(Stream.concat(live)) - }), - ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) + feed.subscribe.pipe(Effect.map((live) => Stream.make(EventFeed.frame(connected)).pipe(Stream.concat(live)))), + ) const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n")) return HttpServerResponse.stream( output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText), diff --git a/packages/server/test/event-feed.test.ts b/packages/server/test/event-feed.test.ts new file mode 100644 index 0000000000..29129c735c --- /dev/null +++ b/packages/server/test/event-feed.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test" +import { AgentV2 } from "@opencode-ai/core/agent" +import { EventV2 } from "@opencode-ai/core/event" +import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { DateTime, Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect" +import { it } from "../../core/test/lib/effect" +import { EventFeed } from "../src/event-feed" + +const Internal = EventV2.ephemeral({ type: "test.internal", schema: { value: Schema.String } }) + +const event = (id: string): EventV2.Payload => ({ + id: EventV2.ID.make(`evt_${id}`), + created: DateTime.makeUnsafe(Date.now()), + type: AgentV2.Event.Updated.type, + data: {}, +}) + +const internal = (value: string): EventV2.Payload => ({ + id: EventV2.ID.create(), + created: DateTime.makeUnsafe(Date.now()), + type: Internal.type, + data: { value }, +}) + +function makeSource() { + let subscriber: EventV2.Subscriber | undefined + return { + observe: (next: EventV2.Subscriber) => + Effect.sync(() => { + subscriber = next + return Effect.sync(() => { + if (subscriber === next) subscriber = undefined + }) + }), + publish: (event: EventV2.Payload) => Effect.suspend(() => (subscriber ? subscriber(event) : Effect.void)), + } +} + +describe("EventFeed", () => { + test("preserves the public SSE frame encoding", () => { + const payload = event("wire") + expect(EventFeed.frame(payload)).toBe( + `data: ${JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(payload))}\n\n`, + ) + }) + + it.effect("encodes once and delivers the same frame to every subscriber", () => + Effect.gen(function* () { + let encodes = 0 + const source = makeSource() + const feed = yield* EventFeed.make(source.observe, { + encode: (event) => { + encodes += 1 + return event.type + }, + }) + const first = yield* feed.subscribe + const second = yield* feed.subscribe + const left = yield* first.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const right = yield* second.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + + yield* source.publish(event("example")) + + expect([Array.from(yield* Fiber.join(left)), Array.from(yield* Fiber.join(right))]).toEqual([ + [AgentV2.Event.Updated.type], + [AgentV2.Event.Updated.type], + ]) + expect(encodes).toBe(1) + }), + ) + + it.effect("fails only the subscriber that exceeds its lag capacity", () => + Effect.gen(function* () { + const source = makeSource() + const feed = yield* EventFeed.make(source.observe, { + capacity: 1, + encode: (event) => event.id, + }) + const slow = yield* feed.subscribe + const fast = yield* feed.subscribe + const first = yield* Deferred.make() + const second = yield* Deferred.make() + const received = new Array() + const fastFiber = yield* fast.pipe( + Stream.take(3), + Stream.runForEach((frame) => + Effect.sync(() => received.push(frame)).pipe( + Effect.andThen( + frame === "evt_one" + ? Deferred.succeed(first, undefined) + : frame === "evt_two" + ? Deferred.succeed(second, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped, + ) + + yield* source.publish(event("one")) + yield* Deferred.await(first) + yield* source.publish(event("two")) + yield* Deferred.await(second) + yield* source.publish(event("three")) + + yield* Fiber.join(fastFiber) + + const result = yield* slow.pipe(Stream.runCollect, Effect.exit) + expect(received).toEqual(["evt_one", "evt_two", "evt_three"]) + expect(Exit.isFailure(result)).toBeTrue() + if (Exit.isSuccess(result)) return + expect(Option.getOrUndefined(Exit.findErrorOption(result))).toBeInstanceOf(EventFeed.SubscriberOverflowError) + }), + ) + + it.effect("filters internal events before they consume subscriber capacity", () => + Effect.gen(function* () { + const source = makeSource() + const feed = yield* EventFeed.make(source.observe, { capacity: 1, encode: (event) => event.type }) + const stream = yield* feed.subscribe + + yield* source.publish(internal("one")) + yield* source.publish(internal("two")) + yield* source.publish(event("public")) + + expect(Array.from(yield* stream.pipe(Stream.take(1), Stream.runCollect))).toEqual([AgentV2.Event.Updated.type]) + }), + ) + + it.effect("disconnects current subscribers after an encoding failure and continues for later subscribers", () => + Effect.gen(function* () { + const source = makeSource() + const feed = yield* EventFeed.make(source.observe, { + encode: (event) => { + if (event.id === EventV2.ID.make("evt_bad")) throw new Error("invalid event") + return event.id + }, + }) + const current = yield* feed.subscribe + const failed = yield* current.pipe(Stream.runCollect, Effect.exit, Effect.forkScoped) + + yield* source.publish(event("bad")) + const exit = yield* Fiber.join(failed) + + const next = yield* feed.subscribe + const received = yield* next.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* source.publish(event("good")) + + expect(Exit.isFailure(exit)).toBeTrue() + if (Exit.isSuccess(exit)) return + expect(Option.getOrUndefined(Exit.findErrorOption(exit))).toBeInstanceOf(EventFeed.EncodingError) + expect(Array.from(yield* Fiber.join(received))).toEqual(["evt_good"]) + }), + ) +}) diff --git a/packages/tui/package.json b/packages/tui/package.json index 40f1fab7ce..9c16e0a731 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -12,7 +12,10 @@ "exports": { ".": "./src/index.tsx", "./builtins": "./src/feature-plugins/builtins.ts", - "./config": "./src/config/index.tsx", + "./config/v1": "./src/config/v1/index.tsx", + "./config/v1/keybind": "./src/config/v1/keybind.ts", + "./config/v2": "./src/config/v2/index.ts", + "./config/v2/keybind": "./src/config/v2/keybind.ts", "./context/args": "./src/context/args.tsx", "./context/epilogue": "./src/context/epilogue.tsx", "./context/exit": "./src/context/exit.tsx", @@ -30,7 +33,6 @@ "./editor-zed": "./src/editor-zed.ts", "./runtime": "./src/runtime.tsx", "./terminal-win32": "./src/terminal-win32.ts", - "./config/keybind": "./src/config/keybind.ts", "./keymap": "./src/keymap.tsx", "./prompt/content": "./src/prompt/content.ts", "./prompt/display": "./src/prompt/display.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index fd6e4ea4de..61144859a9 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -3,7 +3,7 @@ import { registerOpencodeSpinner } from "./component/register-spinner" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Deferred, Effect } from "effect" import { Service } from "@opencode-ai/client/effect" -import { OpenCode } from "@opencode-ai/client/promise" +import { OpenCode } from "@opencode-ai/client" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -12,7 +12,14 @@ import { LogProvider, useLog, type LogSink } from "./context/log" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" -import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" +import { + CliRenderEvents, + createCliRenderer, + MouseButton, + type CliRenderer, + type CliRendererConfig, + type ThemeMode, +} from "@opentui/core" import { RouteProvider, useRoute } from "./context/route" import { Switch, @@ -53,8 +60,6 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" -import { DialogWorkspaceList } from "./component/dialog-workspace-list" -import { DialogConsoleOrg } from "./component/dialog-console-org" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" import { Session } from "./routes/session" @@ -70,7 +75,7 @@ import * as Model from "./util/model" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" -import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" +import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1" import { createTuiApiAdapters } from "./plugin/adapters" import { createTuiApi } from "./plugin/api" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" @@ -121,7 +126,6 @@ const appBindingCommands = [ "variant.cycle", "variant.list", "provider.connect", - "console.org.switch", "opencode.status", "server.pair", "opencode.debug", @@ -131,7 +135,6 @@ const appBindingCommands = [ "help.show", "docs.open", "diff.open", - "workspace.list", "app.debug", "app.console", "app.heap_snapshot", @@ -154,6 +157,14 @@ export type TuiInput = { config: TuiConfig.Resolved onSnapshot?: () => Promise pluginHost: TuiPluginHost + terminalHandoff?: () => Promise< + | { + readonly renderer: CliRenderer + readonly mode: ThemeMode | null + readonly complete: () => void + } + | undefined + > log?: LogSink } @@ -198,6 +209,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.map((response) => response.location.directory), Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), ) + const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined const reconnectEndpoint = input.server.reconnect const reconnect = reconnectEndpoint ? async (attempt: number) => { @@ -229,6 +241,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, } satisfies CliRendererConfig + if (handoff) { + handoff.renderer.useMouse = options.useMouse + return handoff.renderer + } + if (process.env.OPENCODE_DRIVE) { const { Drive } = await import("@opencode-ai/simulation/frontend") return Drive.create(options) @@ -271,7 +288,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { yield* Effect.tryPromise(async () => { // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) - const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark" if (renderer.isDestroyed) return await render(() => { @@ -396,6 +413,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { ) }, renderer) + if (handoff) { + renderer.once(CliRenderEvents.FRAME, handoff.complete) + renderer.requestRender() + } }) yield* Deferred.await(shutdown) return { epilogue: exit.epilogue, reason: exit.reason } @@ -422,10 +443,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() + const sync = useSync() const toast = useToast() const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState - const sync = useSync() const data = useData() const project = useProject() const exit = useExit() @@ -439,7 +460,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // the same problem on every refresh while still re-alerting if the state changes. const mcpAlerted: Record = {} createEffect(() => { - for (const server of data.location.mcp.list() ?? []) { + for (const server of data.location.mcp.server.list() ?? []) { const status = server.status if (status.status !== "failed" && status.status !== "needs_auth") { delete mcpAlerted[server.name] @@ -524,7 +545,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal( - kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), + kv.get("paste_summary_enabled", true), ) // Update terminal window title based on current route and session @@ -578,7 +599,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi let continued = false createEffect(() => { - if (continued || sync.status === "loading" || !args.continue) return + if (continued || !args.continue) return continued = true const location = data.location.default() void sdk.api.session @@ -604,12 +625,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi .catch(toast.error) }) - // Handle --session with --fork: wait for sync to be fully complete before forking - // (session list loads in non-blocking phase for --session, so we must wait for "complete" - // to avoid a race where reconcile overwrites the newly forked session) + // Handle --session with --fork once. let forked = false createEffect(() => { - if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return + if (forked || !args.sessionID || !args.fork) return forked = true void sdk.api.session .fork({ sessionID: args.sessionID }) @@ -618,13 +637,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) const connected = useConnected() - const currentWorktreeWorkspace = createMemo(() => { - const workspaceID = project.workspace.current() - if (!workspaceID) return - const workspace = project.workspace.get(workspaceID) - if (workspace?.type !== "worktree" || !workspace.directory) return - return workspace - }) const appCommands = createMemo(() => [ { @@ -661,31 +673,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.clear() }, }, - { - name: "workspace.copy_path", - title: "Copy worktree path", - category: "Workspace", - enabled: () => currentWorktreeWorkspace() !== undefined, - run: async () => { - const workspace = currentWorktreeWorkspace() - if (!workspace?.directory) return - await clipboard - .write?.(workspace.directory) - .then(() => toast.show({ message: "Copied worktree path", variant: "info" })) - .catch(toast.error) - dialog.clear() - }, - }, - { - name: "workspace.list", - title: "Manage workspaces", - category: "Workspace", - hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slashName: "workspaces", - run: () => { - dialog.replace(() => ) - }, - }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, @@ -754,7 +741,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, { name: "mcp.list", - title: "Toggle MCPs", + title: "MCP Servers", category: "Agent", slashName: "mcps", run: () => { @@ -818,21 +805,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "Integration", }, - ...(sync.data.console_state.switchableOrgCount > 1 - ? [ - { - name: "console.org.switch", - title: "Switch org", - suggested: Boolean(sync.data.console_state.activeOrgName), - slashName: "org", - slashAliases: ["orgs", "switch-org"], - run: () => { - dialog.replace(() => ) - }, - category: "Provider", - }, - ] - : []), { name: "opencode.status", title: "View status", @@ -1040,7 +1012,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi category: "System", run: async () => { kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) - await sync.session.refresh() dialog.clear() }, }, diff --git a/packages/tui/src/attention.ts b/packages/tui/src/attention.ts index b309edcd9d..3fea75e5ff 100644 --- a/packages/tui/src/attention.ts +++ b/packages/tui/src/attention.ts @@ -10,7 +10,7 @@ import type { TuiAttentionSoundPack, TuiAttentionSoundPackInfo, } from "@opencode-ai/plugin/tui" -import { AttentionSoundName, type TuiConfig } from "./config" +import { AttentionSoundName, type TuiConfig } from "./config/v1" import { Schema } from "effect" import stripAnsi from "strip-ansi" import * as TuiAudio from "./audio" diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index 3dd6829c54..a113419653 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -8,7 +8,7 @@ import { useKeymapSelector, useOpencodeKeymap, } from "../keymap" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" type PaletteCommandEntry = ReturnType[number] diff --git a/packages/tui/src/component/dialog-console-org.tsx b/packages/tui/src/component/dialog-console-org.tsx deleted file mode 100644 index 1305a965cb..0000000000 --- a/packages/tui/src/component/dialog-console-org.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { createResource, createMemo, createSignal } from "solid-js" -import { TextAttributes } from "@opentui/core" -import { DialogSelect } from "../ui/dialog-select" -import { useSDK } from "../context/sdk" -import { useDialog } from "../ui/dialog" -import { useToast } from "../ui/toast" -import { useTheme } from "../context/theme" -import { errorMessage } from "../util/error" -import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2" - -type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number] - -const accountHost = (url: string) => { - try { - return new URL(url).host - } catch { - return url - } -} - -const accountLabel = (item: Pick) => - `${item.accountEmail} ${accountHost(item.accountUrl)}` - -export function DialogConsoleOrg() { - const sdk = useSDK() - const dialog = useDialog() - const toast = useToast() - const { theme } = useTheme() - - const [loadError, setLoadError] = createSignal() - - const [orgs] = createResource(() => - sdk.client.experimental.console - .listOrgs({}, { throwOnError: true }) - .then((result) => result.data?.orgs ?? []) - // Catch so the rejected resource never reaches the memos below: reading - // orgs() in an errored state re-throws and tears down the dialog. - .catch((error) => { - setLoadError(error) - return undefined - }), - ) - - const showError = createMemo(() => Boolean(loadError())) - - const current = createMemo(() => orgs()?.find((item) => item.active)) - - const options = createMemo(() => { - if (showError()) return [] - const listed = orgs() - if (listed === undefined) { - return [ - { - title: "Loading orgs...", - value: "loading", - onSelect: () => {}, - }, - ] - } - - if (listed.length === 0) { - return [ - { - title: "No orgs found", - value: "empty", - onSelect: () => {}, - }, - ] - } - - return listed - .toSorted((a, b) => { - const activeAccountA = a.active ? 0 : 1 - const activeAccountB = b.active ? 0 : 1 - if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB - - const accountCompare = accountLabel(a).localeCompare(accountLabel(b)) - if (accountCompare !== 0) return accountCompare - - return a.orgName.localeCompare(b.orgName) - }) - .map((item) => ({ - title: item.orgName, - value: item, - category: accountLabel(item), - categoryView: ( - - {item.accountEmail} - {accountHost(item.accountUrl)} - - ), - onSelect: async () => { - if (item.active) { - dialog.clear() - return - } - - await sdk.client.experimental.console.switchOrg( - { - accountID: item.accountID, - orgID: item.orgID, - }, - { throwOnError: true }, - ) - - await sdk.client.instance.dispose() - toast.show({ - message: `Switched to ${item.orgName}`, - variant: "info", - }) - dialog.clear() - }, - })) - }) - - return ( - - title="Switch org" - options={options()} - current={current()} - renderFilter={!showError()} - locked={showError()} - emptyView={ - showError() ? ( - - - Could not load orgs - - {errorMessage(loadError())} - - ) : undefined - } - /> - ) -} diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 1b962142fc..1ae8bbec77 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,6 +1,10 @@ import { TextAttributes } from "@opentui/core" -import type { IntegrationConnectOauthOutput } from "@opencode-ai/client/promise" -import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2" +import type { + ConnectionInfo, + IntegrationConnectOauthOutput, + IntegrationInfo, + IntegrationOAuthMethod, +} from "@opencode-ai/client" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 003c207be0..34afc6fa48 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -5,11 +5,11 @@ import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useTheme, type Theme } from "../context/theme" import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" -import type { McpServer } from "@opencode-ai/sdk/v2" +import type { McpServer } from "@opencode-ai/client" import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { getScrollAcceleration } from "../util/scroll" import { useBindings } from "../keymap" @@ -45,7 +45,7 @@ export function DialogMcp() { const servers = createMemo(() => pipe( - data.location.mcp.list() ?? [], + data.location.mcp.server.list() ?? [], sortBy( (server) => statusMeta(server.status, theme).rank, (server) => server.name, diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index e0beff09ed..cf6cb361de 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" -import { useSync } from "../context/sync" +import { useData } from "../context/data" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "../context/runtime" import { Locale } from "../util/locale" @@ -17,7 +17,7 @@ import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" -import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise" +import type { ProjectDirectoriesOutput } from "@opencode-ai/client" import { useRoute } from "../context/route" import { DialogProjectCopyName } from "./dialog-project-copy-name" @@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const sdk = useSDK() const dimensions = useTerminalDimensions() const { theme } = useTheme() - const sync = useSync() + const sessionData = useData() const projectContext = useProject() const route = useRoute() const toast = useToast() @@ -132,9 +132,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { }) if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] - const subdirectories = sync.data.session - .filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path)) - .map((session) => session.directory) + const subdirectories = sessionData.session + .list() + .filter( + (session) => + session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), + ) + .map((session) => session.location.directory) .filter((directory) => !roots.some((root) => root.directory === directory)) .filter((directory, index, directories) => directories.indexOf(directory) === index) .map((location) => ({ diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx index 7ac357e1c8..59dcc4a481 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -1,7 +1,7 @@ import { InputRenderable, TextAttributes } from "@opentui/core" import { Slug } from "@opencode-ai/core/util/slug" import { createSignal, onMount } from "solid-js" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { useTheme } from "../context/theme" import { useBindings, useCommandShortcut } from "../keymap" import { useDialog, type DialogContext } from "../ui/dialog" diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx deleted file mode 100644 index 0fd51e3c1c..0000000000 --- a/packages/tui/src/component/dialog-provider.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import { createMemo, createSignal, onMount, Show } from "solid-js" -import { useSync } from "../context/sync" -import { map, pipe, sortBy } from "remeda" -import { DialogSelect } from "../ui/dialog-select" -import { useDialog } from "../ui/dialog" -import { useSDK } from "../context/sdk" -import { DialogPrompt } from "../ui/dialog-prompt" -import { Link } from "../ui/link" -import { useTheme } from "../context/theme" -import { TextAttributes } from "@opentui/core" -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" -import { DialogModel } from "./dialog-model" -import { useToast } from "../ui/toast" -import { isConsoleManagedProvider } from "../util/provider-origin" -import { useConnected } from "./use-connected" -import { useBindings } from "../keymap" -import { useClipboard } from "../context/clipboard" - -const PROVIDER_PRIORITY: Record = { - opencode: 0, - "opencode-go": 1, - openai: 2, - "github-copilot": 3, - anthropic: 4, - google: 5, -} - -const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" -const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ - -type ProviderOptionBase = { - title: string - value: string - description?: string - category: string -} - -type ProviderOption = - | (ProviderOptionBase & { - type: "provider" - providerID: string - }) - | (ProviderOptionBase & { - type: "custom" - }) - -export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] { - return [ - ...pipe( - list, - sortBy( - (x) => PROVIDER_PRIORITY[x.id] ?? 99, - (x) => x.name.toLowerCase(), - (x) => x.id, - ), - map((provider) => ({ - type: "provider" as const, - title: provider.name, - value: provider.id, - providerID: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(API key)", - openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", - }[provider.id], - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", - })), - ), - { - type: "custom", - title: "Other", - value: CUSTOM_PROVIDER_OPTION_VALUE, - description: "Custom provider", - category: "Providers", - }, - ] -} - -export function normalizeCustomProviderID(value: string) { - const providerID = value.trim().replace(/^@ai-sdk\//, "") - if (!CUSTOM_PROVIDER_ID.test(providerID)) return - return providerID -} - -export function createDialogProviderOptions() { - const sync = useSync() - const dialog = useDialog() - const sdk = useSDK() - const toast = useToast() - const { theme } = useTheme() - const onboarded = useConnected() - - async function promptCustomProviderID(): Promise { - const value = await DialogPrompt.show(dialog, "Other", { - placeholder: "Provider id", - description: () => ( - - This only stores a credential. Configure the provider in opencode.json to use it. - - ), - }) - if (value === null) return - - const providerID = normalizeCustomProviderID(value) - if (providerID) return providerID - - toast.show({ - variant: "error", - message: - "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", - }) - return promptCustomProviderID() - } - - const options = createMemo(() => { - return pipe( - providerOptions(sync.data.provider_next.all), - map((provider) => { - if (provider.type === "custom") { - return { - title: provider.title, - value: provider.value, - description: provider.description, - category: provider.category, - async onSelect() { - const providerID = await promptCustomProviderID() - if (!providerID) return - return dialog.replace(() => ) - }, - } - } - - const providerID = provider.providerID - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) - const connected = sync.data.provider_next.connected.includes(providerID) - - return { - title: provider.title, - value: provider.value, - description: provider.description, - footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.category, - gutter: connected && onboarded() ? () => : undefined, - async onSelect() { - if (consoleManaged) return - - const methods = sync.data.provider_auth[providerID] ?? [ - { - type: "api", - label: "API key", - }, - ] - let index: number | null = 0 - if (methods.length > 1) { - index = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: x.label, - value: index, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), - ) - }) - } - if (index == null) return - const method = methods[index] - if (method.type === "oauth") { - let inputs: Record | undefined - if (method.prompts?.length) { - const value = await PromptsMethod({ - dialog, - prompts: method.prompts, - }) - if (!value) return - inputs = value - } - - const result = await sdk.client.provider.oauth.authorize({ - providerID, - method: index, - inputs, - }) - if (result.error) { - toast.show({ - variant: "error", - message: JSON.stringify(result.error), - }) - dialog.clear() - return - } - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) - } - } - if (method.type === "api") { - let metadata: Record | undefined - if (method.prompts?.length) { - const value = await PromptsMethod({ dialog, prompts: method.prompts }) - if (!value) return - metadata = value - } - return dialog.replace(() => ( - - )) - } - }, - } - }), - ) - }) - return options -} - -export function DialogProvider() { - const options = createDialogProviderOptions() - return -} - -interface AutoMethodProps { - index: number - providerID: string - title: string - authorization: ProviderAuthAuthorization -} -function AutoMethod(props: AutoMethodProps) { - const { theme } = useTheme() - const sdk = useSDK() - const dialog = useDialog() - const sync = useSync() - const toast = useToast() - const clipboard = useClipboard() - - useBindings(() => ({ - bindings: [ - { - key: "c", - desc: "Copy provider code", - group: "Dialog", - cmd: () => { - const code = - props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - clipboard - .write?.(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - }, - }, - ], - })) - - onMount(async () => { - const result = await sdk.client.provider.oauth.callback({ - providerID: props.providerID, - method: props.index, - }) - if (result.error) { - toast.show({ - variant: "error", - message: - "name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed" - ? "OAuth authorization failed. Try /connect again." - : JSON.stringify(result.error), - }) - dialog.clear() - return - } - await sdk.client.instance.dispose() - await sync.bootstrap() - dialog.replace(() => ) - }) - - return ( - - - - {props.title} - - dialog.clear()}> - esc - - - - - {props.authorization.instructions} - - Waiting for authorization... - - c copy - - - ) -} - -interface CodeMethodProps { - index: number - title: string - providerID: string - authorization: ProviderAuthAuthorization -} -function CodeMethod(props: CodeMethodProps) { - const { theme } = useTheme() - const sdk = useSDK() - const sync = useSync() - const dialog = useDialog() - const [error, setError] = createSignal(false) - - return ( - { - const { error } = await sdk.client.provider.oauth.callback({ - providerID: props.providerID, - method: props.index, - code: value, - }) - if (!error) { - await sdk.client.instance.dispose() - await sync.bootstrap() - dialog.replace(() => ) - return - } - setError(true) - }} - description={() => ( - - {props.authorization.instructions} - - - Invalid code - - - )} - /> - ) -} - -interface ApiMethodProps { - providerID: string - title: string - metadata?: Record - custom?: boolean -} -function ApiMethod(props: ApiMethodProps) { - const dialog = useDialog() - const sdk = useSDK() - const sync = useSync() - const toast = useToast() - const { theme } = useTheme() - - return ( - - ({ - opencode: ( - - - OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API - key. - - - Go to https://opencode.ai/zen to get a key - - - ), - "opencode-go": ( - - - OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models - with generous usage limits. - - - Go to https://opencode.ai/go and enable OpenCode Go - - - ), - })[props.providerID] ?? undefined - } - onConfirm={async (value) => { - if (!value) return - await sdk.client.auth.set({ - providerID: props.providerID, - auth: { - type: "api", - key: value, - ...(props.metadata ? { metadata: props.metadata } : {}), - }, - }) - await sdk.client.instance.dispose() - await sync.bootstrap() - if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) { - toast.show({ - variant: "info", - message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`, - }) - dialog.clear() - return - } - dialog.replace(() => ) - }} - /> - ) -} - -interface PromptsMethodProps { - dialog: ReturnType - prompts: NonNullable[number][] -} -async function PromptsMethod(props: PromptsMethodProps) { - const inputs: Record = {} - for (const prompt of props.prompts) { - if (prompt.when) { - const value = inputs[prompt.when.key] - if (value === undefined) continue - const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value - if (!matches) continue - } - - if (prompt.type === "select") { - const value = await new Promise((resolve) => { - props.dialog.replace( - () => ( - ({ - title: x.label, - value: x.value, - description: x.hint, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), - ) - }) - if (value === null) return null - inputs[prompt.key] = value - continue - } - - const value = await new Promise((resolve) => { - props.dialog.replace( - () => ( - resolve(value)} /> - ), - () => resolve(null), - ) - }) - if (value === null) return null - inputs[prompt.key] = value - } - return inputs -} diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 8fae5114ab..2d305f54e6 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,6 +1,6 @@ import { createMemo, createResource, createSignal, onMount } from "solid-js" import path from "path" -import type { SessionInfo } from "@opencode-ai/sdk/v2" +import type { SessionInfo } from "@opencode-ai/client" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index bb54dd8713..c8e493ef37 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -5,7 +5,7 @@ import { useDialog } from "../ui/dialog" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" import { useData } from "../context/data" -import type { LocationRef } from "@opencode-ai/sdk/v2" +import type { LocationRef } from "@opencode-ai/client" export type DialogSkillProps = { location?: LocationRef diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 1446dfc4ae..3847bae3c7 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -1,48 +1,17 @@ import { TextAttributes } from "@opentui/core" -import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -import { useSync } from "../context/sync" import { useData } from "../context/data" import { For, Match, Switch, Show, createMemo } from "solid-js" export type DialogStatusProps = {} export function DialogStatus() { - const sync = useSync() const data = useData() const { theme } = useTheme() const dialog = useDialog() - const mcp = createMemo(() => data.location.mcp.list() ?? []) - const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) - - const plugins = createMemo(() => { - const list = sync.data.config.plugin ?? [] - const result = list.map((item) => { - const value = typeof item === "string" ? item : item[0] - if (value.startsWith("file://")) { - const path = fileURLToPath(value) - const parts = path.split("/") - const filename = parts.pop() || path - if (!filename.includes(".")) return { name: filename } - const basename = filename.split(".")[0] - if (basename === "index") { - const dirname = parts.pop() - const name = dirname || basename - return { name } - } - return { name: basename } - } - const index = value.lastIndexOf("@") - if (index <= 0) return { name: value, version: "latest" } - const name = value.substring(0, index) - const version = value.substring(index + 1) - return { name, version } - }) - return result.toSorted((a, b) => a.name.localeCompare(b.name)) - }) - + const mcp = createMemo(() => data.location.mcp.server.list() ?? []) return ( @@ -94,76 +63,6 @@ export function DialogStatus() { - {sync.data.lsp.length > 0 && ( - - {sync.data.lsp.length} LSP Servers - - {(item) => ( - - - • - - - {item.id} {item.root} - - - )} - - - )} - 0} fallback={No Formatters}> - - {enabledFormatters().length} Formatters - - {(item) => ( - - - • - - - {item.name} - - - )} - - - - 0} fallback={No Plugins}> - - {plugins().length} Plugins - - {(item) => ( - - - • - - - {item.name} - {item.version && @{item.version}} - - - )} - - - ) } diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx deleted file mode 100644 index 23e4a8df3a..0000000000 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2" -import { useDialog } from "../ui/dialog" -import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { useSync } from "../context/sync" -import { useProject } from "../context/project" -import { useRoute } from "../context/route" -import { createMemo, createSignal, onMount } from "solid-js" -import { errorMessage } from "../util/error" -import { useSDK } from "../context/sdk" -import { useToast } from "../ui/toast" -import { DialogAlert } from "../ui/dialog-alert" -import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" - -type Adapter = ExperimentalWorkspaceAdapterListResponse[number] - -export type WorkspaceSelection = - | { - type: "none" - } - | { - type: "new" - workspaceType: string - workspaceName: string - } - | { - type: "existing" - workspaceID: string - workspaceType: string - workspaceName: string - } - -type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } -type ExistingWorkspaceSelectValue = { workspace: Workspace } - -export function recentConnectedWorkspaces(input: { - workspaces: readonly WorkspaceInfo[] - status: (workspaceID: string) => string | undefined - limit?: number - omitWorkspaceID?: string -}) { - const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected") - const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) - const recent = workspaces.slice(0, input.limit ?? 3) - - return { recent, hasMore: recent.length < workspaces.length } -} - -export function warpReminderText(dir: string) { - return `The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` -} - -async function loadWorkspaceAdapters(input: { - sdk: ReturnType - sync: ReturnType - toast: ReturnType -}) { - const dir = input.sync.path.directory || process.cwd() - try { - const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir }) - if (response.error) throw response.error - return response.data - } catch (err) { - input.toast.show({ - title: "Failed to load workspace adapters", - message: errorMessage(err), - variant: "error", - }) - return undefined - } -} - -export async function openWorkspaceSelect(input: { - dialog: ReturnType - sdk: ReturnType - sync: ReturnType - project: ReturnType - toast: ReturnType - onSelect: (selection: WorkspaceSelection) => Promise | void -}) { - input.dialog.clear() - await input.sdk.client.experimental.workspace.syncList().catch(() => undefined) - await input.project.workspace.sync().catch(() => undefined) - const adapters = await loadWorkspaceAdapters(input) - if (!adapters) return - input.dialog.replace(() => ) -} - -export async function warpWorkspaceSession(input: { - dialog: ReturnType - sdk: ReturnType - sync: ReturnType - project: ReturnType - toast: ReturnType - sourceWorkspaceID?: string - workspaceID: string | null - sessionID: string - copyChanges: boolean - done?: () => void -}): Promise { - let result - try { - result = await input.sdk.client.experimental.workspace.warp({ - id: input.workspaceID, - sessionID: input.sessionID, - copyChanges: input.copyChanges, - }) - } catch (err) { - input.toast.show({ - title: "Failed to warp session", - message: errorMessage(err), - variant: "error", - }) - return false - } - if (!result?.data) { - if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") { - await DialogAlert.show( - input.dialog, - "Unable to Warp Session", - "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.", - ) - return false - } - - input.toast.show({ - title: "Failed to warp session", - message: errorMessage(result?.error ?? "no response"), - variant: "error", - }) - return false - } - - input.project.workspace.set(input.workspaceID) - - await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - - const dir = input.project.instance.directory() || input.sync.path.directory - if (dir) { - await input.sdk.client.session - .promptAsync({ - sessionID: input.sessionID, - workspace: input.workspaceID ?? undefined, - noReply: true, - parts: [ - { - type: "text", - text: warpReminderText(dir), - synthetic: true, - }, - ], - }) - .catch(() => undefined) - } - - await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) - - if (input.done) { - input.done() - return true - } - input.dialog.clear() - return true -} - -export async function confirmWorkspaceFileChanges(input: { - dialog: ReturnType - sdk: ReturnType - sourceWorkspaceID?: string -}) { - const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) - const fileChangeChoice = status?.data?.length - ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) - : "no" - if (!fileChangeChoice) return - return fileChangeChoice === "yes" -} - -export function DialogWorkspaceSelect(props: { - adapters?: Adapter[] - onSelect: (selection: WorkspaceSelection) => Promise | void -}) { - const dialog = useDialog() - const project = useProject() - const route = useRoute() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const [adapters, setAdapters] = createSignal(props.adapters) - const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) - - onMount(() => { - dialog.setSize("medium") - void (async () => { - if (adapters()) return - const res = await loadWorkspaceAdapters({ sdk, sync, toast }) - if (!res) return - setAdapters(res) - })() - }) - - const options = createMemo[]>(() => { - const list = adapters() - if (!list) return [] - const { recent, hasMore } = recentConnectedWorkspaces({ - workspaces: project.workspace.list(), - status: project.workspace.status, - omitWorkspaceID: omittedWorkspaceID(), - }) - return [ - ...list.map((adapter) => ({ - title: adapter.name, - value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, - description: adapter.description, - category: "New workspace", - })), - { - title: "None", - value: { type: "none" as const }, - description: "Use the local project", - category: "Choose workspace", - }, - ...recent.map((workspace: Workspace) => ({ - title: workspace.name, - description: `(${workspace.type})`, - value: { - type: "existing" as const, - workspaceID: workspace.id, - workspaceType: workspace.type, - workspaceName: workspace.name, - }, - category: "Choose workspace", - })), - ...(hasMore - ? [ - { - title: "View all workspaces", - value: { type: "existing-list" as const }, - description: "Choose from all workspaces", - category: "Choose workspace", - }, - ] - : []), - ] - }) - - if (!adapters()) return null - return ( - - title="Warp" - skipFilter={true} - renderFilter={false} - options={options()} - onSelect={(option) => { - if (!option.value) return - if (option.value.type === "none") { - void props.onSelect(option.value) - return - } - if (option.value.type === "new") { - void props.onSelect(option.value) - return - } - if (option.value.type === "existing") { - void props.onSelect(option.value) - return - } - - dialog.replace(() => ( - - )) - }} - /> - ) -} - -function DialogExistingWorkspaceSelect(props: { - omitWorkspaceID?: string - onSelect: (selection: WorkspaceSelection) => Promise | void -}) { - const project = useProject() - - const options = createMemo[]>(() => - project.workspace - .list() - .filter((workspace) => project.workspace.status(workspace.id) === "connected") - .filter((workspace) => workspace.id !== props.omitWorkspaceID) - .map((workspace: Workspace) => ({ - title: workspace.name, - description: `(${workspace.type})`, - value: { workspace }, - })), - ) - - return ( - - title="Existing Workspace" - options={options()} - onSelect={(option) => { - void props.onSelect({ - type: "existing", - workspaceID: option.value.workspace.id, - workspaceType: option.value.workspace.type, - workspaceName: option.value.workspace.name, - }) - }} - /> - ) -} diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx index 2babeecf8f..231f6c4648 100644 --- a/packages/tui/src/component/dialog-workspace-file-changes.tsx +++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx @@ -1,11 +1,11 @@ import { TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" -import type { VcsFileStatus } from "@opencode-ai/sdk/v2" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import type { VcsFileStatus } from "@opencode-ai/client" import { createMemo, For } from "solid-js" import { createStore } from "solid-js/store" -import { Locale } from "../util/locale" +import { FilePath } from "../ui/file-path" import { useTheme } from "../context/theme" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { useDialog, type DialogContext } from "../ui/dialog" import { getScrollAcceleration } from "../util/scroll" @@ -33,10 +33,13 @@ export function DialogWorkspaceFileChanges(props: { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() + const dimensions = useTerminalDimensions() const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice }) const height = createMemo(() => Math.min(props.files.length, 8)) - const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0)) + const fileNameWidth = createMemo( + () => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))), + ) function confirm() { props.onSelect(store.active) @@ -93,9 +96,7 @@ export function DialogWorkspaceFileChanges(props: { {statusLabel(item.status)} - - {Locale.truncateLeft(item.file, fileNameWidth())} - + diff --git a/packages/tui/src/component/dialog-workspace-list.tsx b/packages/tui/src/component/dialog-workspace-list.tsx deleted file mode 100644 index eab2acf7c8..0000000000 --- a/packages/tui/src/component/dialog-workspace-list.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { Workspace } from "@opencode-ai/sdk/v2" -import { useDialog } from "../ui/dialog" -import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { useProject } from "../context/project" -import { useRoute } from "../context/route" -import { useSync } from "../context/sync" -import { useTheme } from "../context/theme" -import { createMemo, createSignal, onMount } from "solid-js" -import { createStore } from "solid-js/store" -import { errorMessage } from "../util/error" -import { useSDK } from "../context/sdk" -import { useToast } from "../ui/toast" - -type WorkspaceOption = { workspace: Workspace } - -export function DialogWorkspaceList() { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const project = useProject() - const { theme } = useTheme() - const [deleting, setDeleting] = createSignal() - const [removing, setRemoving] = createSignal() - const [expanded, setExpanded] = createStore>({}) - - const current = createMemo(() => { - if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID - return project.workspace.current() - }) - - const options = createMemo[]>(() => - project.workspace - .list() - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((workspace) => { - const status = project.workspace.status(workspace.id) - return { - title: - removing() === workspace.id - ? "Deleting..." - : deleting() === workspace.id - ? `Delete ${workspace.name}? Press delete again` - : workspace.name, - value: { workspace }, - footer: workspace.type, - details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined, - gutter: () => , - } - }), - ) - - function showDetails(workspace: Workspace) { - setExpanded(workspace.id, (open) => !open) - } - - async function remove(workspace: Workspace) { - if (removing()) return - if (deleting() !== workspace.id) { - setDeleting(workspace.id) - return - } - - setDeleting(undefined) - setRemoving(workspace.id) - const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ - error: err, - })) - if (result?.error) { - setRemoving(undefined) - toast.show({ - variant: "error", - title: "Failed to delete workspace", - message: errorMessage(result.error), - }) - return - } - - if (current() === workspace.id) { - project.workspace.set(undefined) - route.navigate({ type: "home" }) - } - await project.workspace.sync() - await sync.bootstrap({ fatal: false }).catch(() => undefined) - setRemoving(undefined) - } - - onMount(() => { - dialog.setSize("large") - void sdk.client.experimental.workspace.syncList().catch(() => undefined) - void project.workspace.sync() - }) - - return ( - { - setDeleting(undefined) - }} - onSelect={(option) => showDetails(option.value.workspace)} - actions={[ - { - command: "session.delete", - title: "delete", - onTrigger: (option) => void remove(option.value.workspace), - }, - ]} - /> - ) -} diff --git a/packages/tui/src/component/dialog-workspace-unavailable.tsx b/packages/tui/src/component/dialog-workspace-unavailable.tsx deleted file mode 100644 index 3181bd8590..0000000000 --- a/packages/tui/src/component/dialog-workspace-unavailable.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { TextAttributes } from "@opentui/core" -import { createStore } from "solid-js/store" -import { For } from "solid-js" -import { useTheme } from "../context/theme" -import { useDialog } from "../ui/dialog" -import { useBindings } from "../keymap" - -export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise }) { - const dialog = useDialog() - const { theme } = useTheme() - const [store, setStore] = createStore({ - active: "restore" as "cancel" | "restore", - }) - - const options = ["cancel", "restore"] as const - - async function confirm() { - if (store.active === "cancel") { - dialog.clear() - return - } - const result = await props.onRestore?.() - if (result === false) return - } - - useBindings(() => ({ - bindings: [ - { key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() }, - { key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") }, - { key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") }, - ], - })) - - return ( - - - - Workspace Unavailable - - dialog.clear()}> - esc - - - - This session is attached to a workspace that is no longer available. - - - Would you like to restore this session into a new workspace? - - - - {(item) => ( - { - setStore("active", item) - void confirm() - }} - > - {item} - - )} - - - - ) -} diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 1b86dbe582..0a58892aef 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -8,11 +8,10 @@ import { createStore } from "solid-js/store" import { useEditorContext } from "../../context/editor" import { useProject } from "../../context/project" import { useSDK } from "../../context/sdk" -import { useSync } from "../../context/sync" import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" -import { useTuiConfig } from "../../config" +import { useTuiConfig } from "../../config/v1" import { useLocation } from "../../context/location" import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" @@ -22,7 +21,7 @@ import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" -import type { FileSystemEntry } from "@opencode-ai/sdk/v2" +import type { FileSystemEntry } from "@opencode-ai/client" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -86,7 +85,6 @@ export function Autocomplete(props: { }) { const editor = useEditorContext() const sdk = useSDK() - const sync = useSync() const data = useData() const project = useProject() const slashes = useCommandSlashes() @@ -285,7 +283,7 @@ export function Autocomplete(props: { }) function normalizeMentionPath(filePath: string) { - const baseDir = location()?.directory || sync.path.directory || paths.cwd + const baseDir = location()?.directory || project.instance.directory() || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -363,7 +361,7 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] const width = props.anchor().width - 4 - for (const res of Object.values(sync.data.mcp_resource)) { + for (const res of data.location.mcp.resource.list(location()) ?? []) { options.push({ display: Locale.truncateMiddle(res.name, width), // Match the name only; matching the URI caused unrelated fuzzy hits. diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d6d09b0b4f..d235d8cf42 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -23,7 +23,6 @@ import { Spinner } from "../spinner" import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" import { useProject } from "../../context/project" -import { useSync } from "../../context/sync" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" @@ -37,7 +36,6 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -48,16 +46,14 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" -import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "../../context/args" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" -import { useTuiConfig } from "../../config" -import { usePromptWorkspace } from "./workspace" +import { useTuiConfig } from "../../config/v1" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" -import { lastAssistantWithUsage } from "../../util/session" +import { contextUsage } from "../../util/session" registerOpencodeSpinner() @@ -156,7 +152,6 @@ export function Prompt(props: PromptProps) { const editor = useEditorContext() const route = useRoute() const project = useProject() - const sync = useSync() const data = useData() const currentLocation = useLocation() const tuiConfig = useTuiConfig() @@ -177,6 +172,7 @@ export function Prompt(props: PromptProps) { const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") + const liveWorkShortcut = useCommandShortcut("session.child.first") const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() @@ -218,7 +214,6 @@ export function Prompt(props: PromptProps) { }) const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() - const workspace = usePromptWorkspace(props.sessionID) const move = usePromptMove({ projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), sessionID: () => props.sessionID, @@ -268,32 +263,24 @@ export function Prompt(props: PromptProps) { if (!props.disabled) input.cursorColor = theme.text }) - const lastUserMessage = createMemo(() => { - if (!props.sessionID) return undefined - const messages = sync.data.message[props.sessionID] - if (!messages) return undefined - return messages.findLast((m): m is UserMessage => m.role === "user") - }) - const usage = createMemo(() => { if (!props.sessionID) return const session = data.session.get(props.sessionID) if (!session) return - const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID) - if (!last) return - - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - if (tokens <= 0) return - - const model = data.location.model - .list(session.location) - ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) - const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = session.cost + const cost = data.session.cost(props.sessionID) + const formattedCost = cost > 0 ? money.format(cost) : undefined + const context = contextUsage( + data.session.message.list(props.sessionID), + data.location.model.list(session.location), + session.revert?.messageID, + ) return { - context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), - cost: cost > 0 ? money.format(cost) : undefined, + context: context + ? context.percent === undefined + ? Locale.number(context.tokens) + : `${Locale.number(context.tokens)} (${context.percent}%)` + : undefined, + cost: formattedCost, } }) @@ -529,17 +516,6 @@ export function Prompt(props: PromptProps) { )) }, }, - { - title: "Warp", - desc: "Change the workspace for the session", - name: "workspace.set", - category: "Session", - enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slashName: "warp", - run: () => { - workspace.open() - }, - }, { title: "Move session", desc: "Move to another project dir", @@ -572,7 +548,6 @@ export function Prompt(props: PromptProps) { "prompt.skills", "session.interrupt", "session.background", - "workspace.set", "session.move", ]), })) @@ -864,6 +839,7 @@ export function Prompt(props: PromptProps) { useBindings(() => { return { + priority: 1, target: inputTarget, enabled: (() => { cursorVersion() @@ -876,8 +852,12 @@ export function Prompt(props: PromptProps) { category: "Prompt", run() { if (input.cursorOffset !== 0) { - if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0 - return false + if (input.scrollY + input.visualCursor.visualRow === 0) { + input.cursorOffset = 0 + return + } + input.moveCursorUp() + return } const item = history.move(-1, input.plainText) @@ -896,6 +876,7 @@ export function Prompt(props: PromptProps) { useBindings(() => { return { + priority: 1, target: inputTarget, enabled: (() => { cursorVersion() @@ -911,9 +892,12 @@ export function Prompt(props: PromptProps) { if ( input.scrollY + input.visualCursor.visualRow === Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) - ) + ) { input.cursorOffset = input.plainText.length - return false + return + } + input.moveCursorDown() + return } const item = history.move(1, input.plainText) @@ -948,8 +932,6 @@ export function Prompt(props: PromptProps) { } async function submitInner() { - workspace.clearNotice() - // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. @@ -958,7 +940,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false - if (workspace.creating() || move.creating()) return false + if (move.creating()) return false if (auto()?.visible) return false if (!store.prompt.text) return false const trimmed = store.prompt.text.trim() @@ -974,29 +956,11 @@ export function Prompt(props: PromptProps) { return false } - const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined - const workspaceID = workspaceSession?.workspaceID - const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined - if (props.sessionID && workspaceID && workspaceStatus !== "connected") { - dialog.replace(() => ( - { - workspace.open() - return false - }} - /> - )) - return false - } - const variant = local.model.variant.current() let sessionID = props.sessionID let session = sessionID ? data.session.get(sessionID) : undefined let finishMoveProgress = false if (sessionID == null) { - const selectedWorkspace = workspace.selection() - const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined - const directory = await move.getDirectory() if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) @@ -1004,9 +968,7 @@ export function Prompt(props: PromptProps) { const created = await sdk.api.session .create({ - location: directory - ? { directory, workspaceID } - : { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID }, + location: directory ? { directory } : location, agent: agent.id, model: { providerID: selectedModel.providerID, @@ -1229,7 +1191,7 @@ export function Prompt(props: PromptProps) { const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 if ( (lineCount >= 3 || pastedContent.length > 150) && - kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) + kv.get("paste_summary_enabled", true) ) { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return @@ -1339,7 +1301,7 @@ export function Prompt(props: PromptProps) { const spinnerDef = createMemo(() => { const agent = status() === "running" - ? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current()) + ? local.agent.current() : local.agent.current() const color = agent ? local.agent.color(agent.id) : theme.border return { @@ -1545,41 +1507,6 @@ export function Prompt(props: PromptProps) { - - {(notice) => ( - - {notice()} - - )} - - - {(label) => ( - - - - - - {(() => { - const item = label() - if (item.type === "new") { - if (workspace.creating()) - return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}` - return ( - <> - Workspace (new {item.workspaceType}) - - ) - } - return ( - <> - Workspace {item.workspaceName} - - ) - })()} - - - )} - {(progress) => ( @@ -1608,6 +1535,9 @@ export function Prompt(props: PromptProps) { 0}> + + {(shortcut) => {shortcut()} } + {(label) => {label()}} diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx deleted file mode 100644 index 57fad0ec3b..0000000000 --- a/packages/tui/src/component/prompt/workspace.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { useDialog } from "../../ui/dialog" -import { useSDK } from "../../context/sdk" -import { useProject } from "../../context/project" -import { useSync } from "../../context/sync" -import { useToast } from "../../ui/toast" -import { errorMessage } from "../../util/error" -import { - confirmWorkspaceFileChanges, - openWorkspaceSelect, - warpWorkspaceSession, - type WorkspaceSelection, -} from "../dialog-workspace-create" -import type { WorkspaceStatus } from "../workspace-label" - -export function usePromptWorkspace(sessionID?: string) { - const dialog = useDialog() - const sdk = useSDK() - const project = useProject() - const sync = useSync() - const toast = useToast() - const [selection, setSelection] = createSignal() - const [creating, setCreating] = createSignal(false) - const [creatingDots, setCreatingDots] = createSignal(3) - const [notice, setNotice] = createSignal() - - async function create(selection: Extract) { - setCreating(true) - let result - try { - result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) - } catch (err) { - setSelection(undefined) - setCreating(false) - toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" }) - return - } - if (result.error || !result.data) { - setSelection(undefined) - setCreating(false) - toast.show({ - title: "Creating workspace failed", - message: errorMessage(result.error ?? "no response"), - variant: "error", - }) - return - } - - await project.workspace.sync() - const workspace = result.data - setSelection({ - type: "existing", - workspaceID: workspace.id, - workspaceType: workspace.type, - workspaceName: workspace.name, - }) - setCreating(false) - return workspace - } - - async function warp(selection: WorkspaceSelection) { - if (!sessionID) { - setSelection(selection) - dialog.clear() - if (selection.type === "new") void create(selection) - return - } - const sourceWorkspaceID = project.workspace.current() - const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) - if (copyChanges === undefined) return - setSelection(selection) - dialog.clear() - - const workspace = - selection.type === "none" - ? { id: null, name: "local project" } - : selection.type === "existing" - ? { id: selection.workspaceID, name: selection.workspaceName } - : await create(selection) - if (!workspace) return - - const warped = await warpWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - sourceWorkspaceID, - workspaceID: workspace.id, - sessionID, - copyChanges, - }) - if (warped) showNotice(workspace.name) - } - - function showNotice(name: string) { - setNotice(`Warped to ${name}`) - setTimeout(() => setNotice(undefined), 4000) - } - - function clearNotice() { - setNotice(undefined) - } - - function open() { - void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp }) - } - - createEffect(() => { - if (!creating()) { - setCreatingDots(3) - return - } - const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000) - onCleanup(() => clearInterval(timer)) - }) - - const label = createMemo< - | { type: "new"; workspaceType: string } - | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } - | undefined - >(() => { - const selected = selection() - if (!selected) return - if (selected.type === "none") return - if (sessionID && !creating()) return - if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType } - return { - type: "existing", - workspaceType: selected.workspaceType, - workspaceName: selected.workspaceName, - status: selected.type === "existing" ? "connected" : undefined, - } - }) - - return { selection, creating, creatingDots, notice, label, open, warp, clearNotice } -} diff --git a/packages/tui/src/component/workspace-label.tsx b/packages/tui/src/component/workspace-label.tsx deleted file mode 100644 index 4dd8982ae7..0000000000 --- a/packages/tui/src/component/workspace-label.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useTheme } from "../context/theme" - -export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" - -export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { - const { theme } = useTheme() - const color = () => { - if (props.status === "connected") return theme.success - if (props.status === "error") return theme.error - return theme.textMuted - } - - return ( - <> - {props.icon ? : undefined} - {props.name} ({props.type}) - - ) -} diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/v1/index.tsx similarity index 100% rename from packages/tui/src/config/index.tsx rename to packages/tui/src/config/v1/index.tsx diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/v1/keybind.ts similarity index 96% rename from packages/tui/src/config/keybind.ts rename to packages/tui/src/config/v1/keybind.ts index 0f97f8277d..4b108f5303 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -97,10 +97,8 @@ export const Definitions = { session_interrupt: keybind("escape", "Interrupt current session"), session_background: keybind("ctrl+b", "Background blocking session tools"), session_compact: keybind("c", "Compact the session"), - session_toggle_timestamps: keybind("none", "Toggle message timestamps"), - session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), session_queued_prompts: keybind("q", "Manage queued prompts"), - session_child_first: keybind("down", "Toggle subagent picker"), + session_child_first: keybind("down,down", "Toggle subagent picker"), session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), @@ -125,7 +123,6 @@ export const Definitions = { model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), mcp_list: keybind("none", "List MCP servers"), provider_connect: keybind("none", "Connect integration"), - console_org_switch: keybind("none", "Switch console organization"), agent_list: keybind("a", "List agents"), agent_cycle: keybind("tab", "Next agent"), agent_cycle_reverse: keybind("shift+tab", "Previous agent"), @@ -147,7 +144,6 @@ export const Definitions = { messages_undo: keybind("u", "Undo message"), messages_redo: keybind("r", "Redo message"), messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), - tool_details: keybind("none", "Toggle tool details visibility"), display_thinking: keybind("none", "Toggle thinking blocks visibility"), prompt_submit: keybind("none", "Submit prompt"), @@ -156,7 +152,6 @@ export const Definitions = { prompt_stash: keybind("none", "Stash prompt"), prompt_stash_pop: keybind("none", "Pop stashed prompt"), prompt_stash_list: keybind("none", "List stashed prompts"), - workspace_set: keybind("none", "Set workspace"), input_clear: keybind("ctrl+c", "Clear input field"), input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), @@ -208,7 +203,6 @@ export const Definitions = { "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.project_copy.generate": keybind("tab", "Generate project copy name"), - "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"), @@ -305,8 +299,6 @@ export const CommandMap = { session_interrupt: "session.interrupt", session_background: "session.background", session_compact: "session.compact", - session_toggle_timestamps: "session.toggle.timestamps", - session_toggle_generic_tool_output: "session.toggle.generic_tool_output", session_queued_prompts: "session.queued_prompts", session_child_first: "session.child.first", session_child_cycle: "session.child.next", @@ -332,7 +324,6 @@ export const CommandMap = { model_cycle_favorite_reverse: "model.cycle_favorite_reverse", mcp_list: "mcp.list", provider_connect: "provider.connect", - console_org_switch: "console.org.switch", agent_list: "agent.list", agent_cycle: "agent.cycle", agent_cycle_reverse: "agent.cycle.reverse", @@ -353,7 +344,6 @@ export const CommandMap = { messages_undo: "session.undo", messages_redo: "session.redo", messages_toggle_conceal: "session.toggle.conceal", - tool_details: "session.toggle.actions", display_thinking: "session.toggle.thinking", prompt_submit: "prompt.submit", prompt_editor_context_clear: "prompt.editor_context.clear", @@ -361,7 +351,6 @@ export const CommandMap = { prompt_stash: "prompt.stash", prompt_stash_pop: "prompt.stash.pop", prompt_stash_list: "prompt.stash.list", - workspace_set: "workspace.set", input_clear: "prompt.clear", input_paste: "prompt.paste", input_submit: "input.submit", diff --git a/packages/tui/src/config/v2/index.ts b/packages/tui/src/config/v2/index.ts new file mode 100644 index 0000000000..23ebc01e56 --- /dev/null +++ b/packages/tui/src/config/v2/index.ts @@ -0,0 +1,97 @@ +export * as TuiConfigV2 from "." + +import { Schema } from "effect" +import { TuiKeybind } from "./keybind" + +export const Plugin = Schema.Union([ + Schema.String, + Schema.Struct({ + package: Schema.String, + options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + }), +]) + +export const Info = Schema.Struct({ + theme: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + mode: Schema.optional(Schema.Literals(["system", "dark", "light"])), + }), + ), + keybinds: Schema.optional(TuiKeybind.KeybindOverrides), + plugins: Schema.optional(Schema.Array(Plugin)), + leader: Schema.optional( + Schema.Struct({ + timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + }), + ), + scroll: Schema.optional( + Schema.Struct({ + speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))), + acceleration: Schema.optional(Schema.Boolean), + }), + ), + attention: Schema.optional( + Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + notifications: Schema.optional(Schema.Boolean), + sound: Schema.optional(Schema.Boolean), + volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))), + sound_pack: Schema.optional(Schema.String), + sounds: Schema.optional( + Schema.Record( + Schema.Literals(["default", "question", "permission", "error", "done", "subagent_done"]), + Schema.optionalKey(Schema.String), + ), + ), + }), + ), + diffs: Schema.optional( + Schema.Struct({ + wrap: Schema.optional(Schema.Literals(["word", "none"])), + tree: Schema.optional(Schema.Boolean), + single: Schema.optional(Schema.Boolean), + view: Schema.optional(Schema.Literals(["auto", "split", "unified"])), + }), + ), + terminal: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.Boolean), + }), + ), + composer: Schema.optional( + Schema.Struct({ + file_context: Schema.optional(Schema.Boolean), + paste_summary: Schema.optional(Schema.Boolean), + }), + ), + session: Schema.optional( + Schema.Struct({ + sidebar: Schema.optional(Schema.Literals(["auto", "hide"])), + scrollbar: Schema.optional(Schema.Boolean), + thinking: Schema.optional(Schema.Literals(["show", "hide"])), + group_exploration: Schema.optional(Schema.Boolean), + directory_filter: Schema.optional(Schema.Boolean), + }), + ), + which_key: Schema.optional( + Schema.Struct({ + layout: Schema.optional(Schema.Literals(["dock", "overlay"])), + pending_preview: Schema.optional(Schema.Boolean), + }), + ), + hints: Schema.optional( + Schema.Struct({ + tips: Schema.optional(Schema.Boolean), + getting_started: Schema.optional(Schema.Boolean), + }), + ), + updates: Schema.optional( + Schema.Struct({ + skipped: Schema.optional(Schema.String), + }), + ), + animations: Schema.optional(Schema.Boolean), + mouse: Schema.optional(Schema.Boolean), +}) +export type Info = Schema.Schema.Type diff --git a/packages/tui/src/config/v2/keybind.ts b/packages/tui/src/config/v2/keybind.ts new file mode 100644 index 0000000000..5dcd6d4f67 --- /dev/null +++ b/packages/tui/src/config/v2/keybind.ts @@ -0,0 +1,6 @@ +export * as TuiKeybind from "./keybind" + +import { Schema } from "effect" + +export const KeybindOverrides = Schema.Struct({}) +export type KeybindOverrides = Schema.Schema.Type diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 59f15d74d6..c5f4fddd85 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -9,6 +9,7 @@ import type { FormInfo, IntegrationInfo, LocationRef, + McpResource, McpServer, ModelInfo, PermissionSavedInfo, @@ -21,10 +22,12 @@ import type { SessionMessageAssistantText, SessionMessageAssistantTool, SessionInfo, - Shell, + SessionPendingInfo, + ShellInfo, SkillInfo, -} from "@opencode-ai/sdk/v2" -import type { OpenCodeEvent } from "@opencode-ai/client/promise" + OpenCodeEvent, +} from "@opencode-ai/client" +import type { Data } from "@opencode-ai/plugin/v2/tui/context" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" @@ -43,17 +46,20 @@ type LocationData = { agent?: AgentInfo[] command?: CommandInfo[] integration?: IntegrationInfo[] - mcp?: McpServer[] + mcp?: { + server?: McpServer[] + resource?: McpResource[] + } model?: ModelInfo[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. - shell?: Record + shell?: Record skill?: SkillInfo[] } -type Data = { +type Store = { session: { info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the @@ -62,7 +68,9 @@ type Data = { family: Record status: Record message: Record + pending: Record input: Record + compaction: Record permission: Record // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record @@ -84,13 +92,15 @@ function locationQuery(ref?: LocationRef) { export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { - const [store, setStore] = createStore({ + const [store, setStore] = createStore({ session: { info: {}, family: {}, status: {}, message: {}, + pending: {}, input: {}, + compaction: {}, permission: {}, form: {}, }, @@ -112,6 +122,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "status", sessionID, status) } + function addCompaction(sessionID: string, inputID: string) { + if (store.session.compaction[sessionID]?.includes(inputID)) return + setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID]) + } + + function addPending(item: SessionPendingInfo) { + if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return + setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item]) + } + + function removePending(sessionID: string, inputID?: string) { + if (!inputID) return + setStore( + "session", + "pending", + sessionID, + (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID), + ) + } + + function removeCompaction(sessionID: string, inputID?: string) { + if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return + setStore( + "session", + "compaction", + sessionID, + store.session.compaction[sessionID].filter((id) => id !== inputID), + ) + } + const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -220,7 +260,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ delete draft.info[sessionID] delete draft.status[sessionID] delete draft.message[sessionID] + delete draft.pending[sessionID] delete draft.input[sessionID] + delete draft.compaction[sessionID] delete draft.permission[sessionID] delete draft.form[sessionID] for (const [rootID, family] of Object.entries(draft.family)) { @@ -308,6 +350,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } break case "session.input.promoted": { + removePending(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return @@ -328,6 +371,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break } case "session.input.admitted": + addPending({ + id: event.data.inputID, + sessionID: event.data.sessionID, + admittedSeq: event.durable.seq, + timeCreated: event.created, + ...event.data.input, + }) if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) setStore("session", "input", event.data.sessionID, [ ...(store.session.input[event.data.sessionID] ?? []), @@ -611,8 +661,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": + addPending({ + id: event.data.inputID, + sessionID: event.data.sessionID, + admittedSeq: event.durable.seq, + timeCreated: event.created, + type: "compaction", + }) + addCompaction(event.data.sessionID, event.data.inputID) break case "session.compaction.started": + removePending(event.data.sessionID, event.data.inputID) + removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.inputID ?? messageIDFromEvent(event.id), @@ -669,16 +729,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") const current = draft[position] if (current?.type === "compaction") { - draft[position] = { - id: current.id, - type: "compaction", + Object.assign(current, { status: "completed", reason: event.data.reason, summary: event.data.text, recent: event.data.recent, - metadata: current.metadata, - time: current.time, - } + }) return } message.append(draft, index, { @@ -693,6 +749,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": + removePending(event.data.sessionID, event.data.inputID) + removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") const current = draft[position] @@ -784,7 +842,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": if (bootstrapping) break - void result.location.mcp.refresh(event.location) + void result.location.mcp.server.refresh(event.location) + break + case "mcp.resources.changed": + void result.location.mcp.resource.refresh(event.location) break } } @@ -805,6 +866,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ family(sessionID: string) { return store.session.family[resolveRoot(sessionID)] ?? [] }, + cost(sessionID: string) { + const session = store.session.info[sessionID] + if (!session) return 0 + if (session.parentID) return session.cost + return (store.session.family[sessionID] ?? [sessionID]).reduce( + (total, id) => total + (store.session.info[id]?.cost ?? 0), + 0, + ) + }, status(sessionID: string) { return store.session.status[sessionID] ?? "idle" }, @@ -816,14 +886,40 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, + compaction: { + list(sessionID: string) { + return store.session.compaction[sessionID] ?? [] + }, + async refresh(sessionID: string) { + await result.session.pending.refresh(sessionID) + }, + }, + pending: { + list(sessionID: string) { + return store.session.pending[sessionID] ?? [] + }, + async refresh(sessionID: string) { + const pending = await sdk.api.session.pending.list({ sessionID }) + setStore("session", "pending", sessionID, reconcile(pending)) + setStore( + "session", + "input", + sessionID, + reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), + ) + setStore( + "session", + "compaction", + sessionID, + reconcile(pending.filter((item) => item.type === "compaction").map((item) => item.id)), + ) + }, + }, async refresh(sessionID: string) { setStore("session", "info", sessionID, await sdk.api.session.get({ sessionID })) registerSession(sessionID) }, message: { - ids(sessionID: string) { - return (store.session.message[sessionID] ?? []).map((message) => message.id) - }, list(sessionID: string) { return store.session.message[sessionID] ?? [] }, @@ -943,13 +1039,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, mcp: { - list(location?: LocationRef) { - return store.location[locationKey(location ?? defaultLocation())]?.mcp + server: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server + }, + async refresh(ref?: LocationRef) { + const result = await sdk.api.mcp.list({ location: locationQuery(ref) }) + const key = locationKey(result.location) + setStore("location", key, { + ...store.location[key], + mcp: { ...store.location[key]?.mcp, server: result.data }, + }) + }, }, - async refresh(ref?: LocationRef) { - const result = await sdk.api["server.mcp"].list({ location: locationQuery(ref) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], mcp: result.data }) + resource: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource + }, + async refresh(ref?: LocationRef) { + const result = await sdk.api.mcp.resource.catalog({ location: locationQuery(ref) }) + const key = locationKey(result.location) + setStore("location", key, { + ...store.location[key], + mcp: { ...store.location[key]?.mcp, resource: result.data.resources }, + }) + }, }, }, model: { @@ -994,6 +1108,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, } + result satisfies Data async function bootstrap() { if (bootstrapping) return bootstrapping @@ -1045,7 +1160,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), - result.location.mcp.refresh(), + result.location.mcp.server.refresh(), + result.location.mcp.resource.refresh(), result.location.model.refresh(), result.location.provider.refresh(), result.location.reference.refresh(), @@ -1093,9 +1209,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ sdk.event.listen(({ details }) => { if (details.type === "server.connected") { const messages = connected ? Object.keys(store.session.message) : [] + const compactions = connected ? Object.keys(store.session.compaction) : [] connected = true refreshActive() - void Promise.allSettled([bootstrap(), ...messages.map(result.session.message.refresh)]) + void Promise.allSettled([ + bootstrap(), + ...messages.map(result.session.message.refresh), + ...compactions.map(result.session.compaction.refresh), + ]) return } handleEvent(details) diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index b107a40b81..2c566f3373 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -1,17 +1,13 @@ import { createMemo } from "solid-js" import { useProject } from "./project" -import { useSync } from "./sync" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "./runtime" export function useDirectory() { const project = useProject() - const sync = useSync() const paths = useTuiPaths() return createMemo(() => { const directory = project.instance.path().directory || paths.cwd - const result = abbreviateHome(directory, paths.home) - if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch - return result + return abbreviateHome(directory, paths.home) }) } diff --git a/packages/tui/src/context/event.ts b/packages/tui/src/context/event.ts index cf71a01b82..9accb5ec5e 100644 --- a/packages/tui/src/context/event.ts +++ b/packages/tui/src/context/event.ts @@ -1,4 +1,4 @@ -import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import type { OpenCodeEvent } from "@opencode-ai/client" import { useSDK } from "./sdk" type EventMetadata = { diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 5c16acdaf8..e8be304866 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -1,7 +1,6 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" import { useEvent } from "./event" import path from "path" import { useTuiPaths } from "./runtime" @@ -52,7 +51,6 @@ export function recentModels( export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { - const sync = useSync() const data = useData() const sdk = useSDK() const toast = useToast() @@ -210,16 +208,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - if (sync.data.config.model) { - const { providerID, modelID } = parseModel(sync.data.config.model) - if (isModelValid({ providerID, modelID })) { - return { - providerID, - modelID, - } - } - } - for (const item of modelStore.recent) { if (isModelValid(item)) { return item @@ -453,7 +441,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const slots = createMemo(() => { - const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id)) + const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id)) return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9) }) @@ -507,12 +495,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const mcp = { isEnabled(name: string) { - const status = sync.data.mcp[name] - return status?.status === "connected" + return data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status === "connected" }, async toggle(name: string) { - const status = sync.data.mcp[name] - if (status?.status === "connected") { + const status = data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status + if (status === "connected") { // Disable: disconnect the MCP await sdk.client.mcp.disconnect({ name }) } else { diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 0f3fca1359..52f73c0bbb 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,4 +1,4 @@ -import type { LocationRef } from "@opencode-ai/sdk/v2" +import type { LocationRef } from "@opencode-ai/client" import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" const context = createContext>() diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index 405cec1894..73584ceda1 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -1,11 +1,8 @@ import { batch } from "solid-js" -import type { Path, Workspace } from "@opencode-ai/sdk/v2" import { createStore, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" - export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ name: "Project", init: () => { @@ -17,7 +14,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex config: "", worktree: "", directory: process.cwd(), - } satisfies Path + } const [store, setStore] = createStore({ project: { @@ -30,42 +27,26 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }, workspace: { current: undefined as string | undefined, - list: [] as Workspace[], - status: {} as Record, }, }) async function sync() { const workspace = store.workspace.current const location = { workspace } - const [instancePath, project] = await Promise.all([ - sdk.client.path.get({ workspace }), - sdk.api.project.current({ location }), - ]) - const directories = await sdk.api.project.directories({ projectID: project.id, location }) + const current = await sdk.api.location.get({ location }) + const directories = await sdk.api.project.directories({ projectID: current.project.id, location }) batch(() => { - setStore("instance", "path", reconcile(instancePath.data || defaultPath)) - setStore("project", "id", project.id) - setStore("project", "worktree", project.directory) + setStore( + "instance", + "path", + reconcile({ ...defaultPath, worktree: current.project.directory, directory: current.directory }), + ) + setStore("project", "id", current.project.id) + setStore("project", "worktree", current.project.directory) setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory) }) } - async function syncWorkspace() { - const listed = await sdk.client.experimental.workspace.list().catch(() => undefined) - if (!listed?.data) return - const status = await sdk.client.experimental.workspace.status().catch(() => undefined) - const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status])) - - batch(() => { - setStore("workspace", "list", reconcile(listed.data)) - setStore("workspace", "status", reconcile(next)) - if (!listed.data.some((item) => item.id === store.workspace.current)) { - setStore("workspace", "current", undefined) - } - }) - } - return { data: store, project() { @@ -88,19 +69,6 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex if (store.workspace.current === workspace) return setStore("workspace", "current", workspace) }, - list() { - return store.workspace.list - }, - get(workspaceID: string) { - return store.workspace.list.find((item) => item.id === workspaceID) - }, - status(workspaceID: string) { - return store.workspace.status[workspaceID] - }, - statuses() { - return store.workspace.status - }, - sync: syncWorkspace, }, sync, } diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 3c13ec972c..d1364378ef 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,4 +1,4 @@ -import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client/promise" +import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup, onMount } from "solid-js" @@ -93,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (abort.signal.aborted || controller.signal.aborted) return if (event.done) return new Error("Event stream disconnected") if ("durable" in event.value) - log.info("event", { + log.debug("event", { type: event.value.type, aggregateID: event.value.durable.aggregateID, seq: event.value.durable.seq, diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index c35927d6f0..f0133f85c0 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -2,7 +2,6 @@ import type { Agent, Command, Config, - ConsoleState, FormatterStatus, LspStatus, McpResource, @@ -11,8 +10,6 @@ import type { Part, PermissionRequest, Provider, - ProviderAuthMethod, - ProviderListResponse, QuestionRequest, Session, FileDiffInfo, @@ -22,11 +19,6 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" import { useProject } from "./project" -const emptyConsoleState: ConsoleState = { - consoleManagedProviders: [], - switchableOrgCount: 0, -} - export const { context: SyncContext, use: useSync, @@ -38,10 +30,6 @@ export const { const [store, setStore] = createStore<{ status: "loading" | "partial" | "complete" provider: Provider[] - provider_default: Record - provider_next: ProviderListResponse - console_state: ConsoleState - provider_auth: Record agent: Agent[] command: Command[] permission: Record @@ -59,14 +47,6 @@ export const { }>({ status: "complete", provider: [], - provider_default: {}, - provider_next: { - all: [], - default: {}, - connected: [], - }, - console_state: emptyConsoleState, - provider_auth: {}, agent: [], command: [], permission: {}, diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 909dd69ac2..79456b8498 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -23,7 +23,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useKV } from "./kv" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { Global } from "@opencode-ai/core/global" import { Glob } from "@opencode-ai/core/util/glob" import { readFile } from "node:fs/promises" diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 41bee5da5a..af1277b5c2 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -4,24 +4,45 @@ import { createMemo, Match, Show, Switch } from "solid-js" import { abbreviateHome } from "../../runtime" import { useTuiPaths } from "../../context/runtime" import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { FilePath } from "../../ui/file-path" +import { useTerminalDimensions } from "@opentui/solid" const id = "internal:home-footer" -function Directory(props: { api: TuiPluginApi }) { +function Directory(props: { api: TuiPluginApi; maxWidth: number }) { const theme = () => props.api.theme.current const destination = useHomeSessionDestination() const paths = useTuiPaths() const dir = createMemo(() => { const selected = destination?.destination() if (!selected || selected.type === "new") return - const out = abbreviateHome(selected.directory, paths.home) const branch = selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined - if (branch) return out + ":" + branch - return out + return { path: abbreviateHome(selected.directory, paths.home), branch } }) - return {(value) => {value()}} + return ( + + {(value) => { + const suffix = () => (value().branch ? `:${value().branch}` : "") + const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2)) + return ( + + + + + {suffix()} + + + + ) + }} + + ) } function Mcp(props: { api: TuiPluginApi }) { @@ -62,6 +83,16 @@ function Version(props: { api: TuiPluginApi }) { } function View(props: { api: TuiPluginApi }) { + const dimensions = useTerminalDimensions() + const mcpWidth = createMemo(() => { + const list = props.api.state.mcp() + if (list.length === 0) return 0 + const count = list.filter((item) => item.status === "connected").length + return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2 + }) + const directoryWidth = createMemo(() => + Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()), + ) return ( - + diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index ae41cec303..f41fb23787 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,8 +1,8 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" -import { createMemo } from "solid-js" +import { createMemo, Show } from "solid-js" import { useData } from "../../context/data" -import { lastAssistantWithUsage } from "../../util/session" +import { contextUsage } from "../../util/session" const id = "internal:sidebar-context" @@ -16,35 +16,25 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => data.session.message.list(props.session_id)) const session = createMemo(() => data.session.get(props.session_id)) - const cost = createMemo(() => session()?.cost ?? 0) + const cost = createMemo(() => data.session.cost(props.session_id)) - const state = createMemo(() => { - const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID) - if (!last) { - return { - tokens: 0, - percent: null, - } - } - - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = data.location - .model.list(session()?.location) - ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) - return { - tokens, - percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, - } - }) + const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID)) return ( Context - {state().tokens.toLocaleString()} tokens - {state().percent ?? 0}% used + Not measured}> + {(value) => ( + <> + {value().tokens.toLocaleString()} tokens + + {value().percent}% used + + + )} + {money.format(cost())} spent ) diff --git a/packages/tui/src/feature-plugins/sidebar/files.tsx b/packages/tui/src/feature-plugins/sidebar/files.tsx index 01f33f647d..e76db91444 100644 --- a/packages/tui/src/feature-plugins/sidebar/files.tsx +++ b/packages/tui/src/feature-plugins/sidebar/files.tsx @@ -1,7 +1,7 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, For, Show, createSignal } from "solid-js" -import { Locale } from "../../util/locale" +import { FilePath } from "../../ui/file-path" const id = "internal:sidebar-files" @@ -31,9 +31,11 @@ function View(props: { api: TuiPluginApi; session_id: string }) { {(item) => ( - - {Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))} - + +{item.additions} diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index 6fb51ffafc..b0fb681153 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -3,6 +3,7 @@ import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Show } from "solid-js" import { abbreviateHome } from "../../runtime" import { useTuiPaths } from "../../context/runtime" +import { FilePath } from "../../ui/file-path" const id = "internal:sidebar-footer" @@ -16,16 +17,12 @@ function View(props: { api: TuiPluginApi; directory: string }) { ) const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) - const path = createMemo(() => { - const out = abbreviateHome(props.directory, paths.home) + const location = createMemo(() => { const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined - const text = branch ? out + ":" + branch : out - const list = text.split("/") - return { - parent: list.slice(0, -1).join("/"), - name: list.at(-1) ?? "", - } + return { path: abbreviateHome(props.directory, paths.home), branch } }) + const suffix = createMemo(() => (location().branch ? `:${location().branch}` : "")) + const suffixWidth = createMemo(() => Math.min(Bun.stringWidth(suffix()), 36)) return ( @@ -62,10 +59,19 @@ function View(props: { api: TuiPluginApi; directory: string }) { - - {path().parent}/ - {path().name} - + + + + + {suffix()} + + + Open diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 1bee45dedc..d5e1e56e7b 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui" -import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client" import { TextAttributes, type BorderSides, @@ -11,6 +11,7 @@ import { import { LANGUAGE_EXTENSIONS } from "../../util/filetype" import { useBindings, useCommandShortcut } from "../../keymap" import { useTheme } from "../../context/theme" +import { useSDK } from "../../context/sdk" import { useTerminalDimensions } from "@opentui/solid" import path from "path" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" @@ -43,7 +44,7 @@ const VCS_DIFF_CONTEXT_LINES = 12 const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree" const KV_SINGLE_PATCH = "diff_viewer_single_patch" const KV_VIEW = "diff_viewer_view" -type DiffMode = "git" | "branch" | "last-turn" +type DiffMode = "working" | "branch" | "last-turn" type DiffViewerFocus = "patches" | "files" type DiffView = "split" | "unified" type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number } @@ -56,7 +57,7 @@ type DiffFile = { readonly status: "added" | "deleted" | "modified" } -const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] => +const normalizeDiffs = (diffs: readonly (FileDiffInfo | FileDiffLegacyInfo)[]): DiffFile[] => diffs.flatMap((item) => item.file ? [ @@ -90,6 +91,7 @@ function diffSourceLabel(mode: DiffMode) { function DiffViewer(props: { api: TuiPluginApi }) { const dimensions = useTerminalDimensions() + const sdk = useSDK() const themeState = useTheme() const theme = () => props.api.theme.current const params = () => @@ -101,7 +103,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { returnRoute?: TuiRouteCurrent } | undefined - const mode = () => params()?.mode ?? "git" + const mode = () => params()?.mode ?? "working" const diffInput = createMemo(() => { const sessionID = params()?.sessionID return { @@ -122,9 +124,12 @@ function DiffViewer(props: { api: TuiPluginApi }) { return normalizeDiffs(result.data ?? []) } - const result = await props.api.client.vcs.diff( - { directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES }, - { throwOnError: true }, + const result = await sdk.api.vcs.diff( + { + location: input.directory ? { directory: input.directory } : undefined, + mode: input.mode, + context: VCS_DIFF_CONTEXT_LINES, + }, ) return normalizeDiffs(result.data ?? []) }) @@ -686,7 +691,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { return [ { title: "Working tree", - value: "git" as const, + value: "working" as const, description: "Show current git changes", }, ...(vcs?.branch && vcs.default_branch && vcs.branch !== vcs.default_branch @@ -1060,7 +1065,7 @@ const tui: TuiPlugin = async (api) => { namespace: "palette", run() { api.route.navigate(ROUTE, { - mode: "git", + mode: "working", sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, returnRoute: api.route.current, }) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 59aa9c1af3..871f8e6eec 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -1,4 +1,4 @@ -import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import type { OpenCodeEvent } from "@opencode-ai/client" import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index 8d0aefc8f5..e4dc694935 100644 --- a/packages/tui/src/keymap.tsx +++ b/packages/tui/src/keymap.tsx @@ -14,8 +14,8 @@ import { } from "@opentui/keymap/extras" import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid" import { createMemo, type Accessor } from "solid-js" -import { useTuiConfig } from "./config" -import { TuiKeybind } from "./config/keybind" +import { useTuiConfig } from "./config/v1" +import { TuiKeybind } from "./config/v1/keybind" export const LEADER_TOKEN = "leader" export const OPENCODE_BASE_MODE = "base" @@ -193,6 +193,10 @@ function formatOptions(config: FormatConfig) { [LEADER_TOKEN]: leaderDisplay(config), }, keyNameAliases: { + up: "↑", + down: "↓", + left: "←", + right: "→", pageup: "pgup", pagedown: "pgdn", delete: "del", diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 7da68b8e86..088557e931 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -1,5 +1,5 @@ import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui" -import type { TuiConfig } from "../config" +import type { TuiConfig } from "../config/v1" import type { useEvent } from "../context/event" import type { useRoute } from "../context/route" import type { useSDK } from "../context/sdk" @@ -100,7 +100,7 @@ function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { function stateApi(sync: ReturnType, data: ReturnType): TuiPluginApi["state"] { return { get ready() { - return sync.ready + return true }, get config() { return sync.data.config @@ -120,7 +120,7 @@ function stateApi(sync: ReturnType, data: ReturnType, data: ReturnType ({ id: item.id, root: item.root, status: item.status })) }, mcp() { - return Object.entries(sync.data.mcp) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, item]) => ({ - name, - status: item.status, - error: item.status === "failed" ? item.error : undefined, - })) + return (data.location.mcp.server.list() ?? []) + .toSorted((a, b) => a.name.localeCompare(b.name)) + .flatMap((item) => + item.status.status === "pending" + ? [] + : [ + { + name: item.name, + status: item.status.status, + error: item.status.status === "failed" ? item.status.error : undefined, + }, + ], + ) }, } } diff --git a/packages/tui/src/plugin/command-shim.ts b/packages/tui/src/plugin/command-shim.ts index 61eb833fe7..d909f328cb 100644 --- a/packages/tui/src/plugin/command-shim.ts +++ b/packages/tui/src/plugin/command-shim.ts @@ -1,6 +1,6 @@ // Legacy `api.command` bridge for v1 plugins; remove in v2. import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui" -import { TuiKeybind } from "../config/keybind" +import { TuiKeybind } from "../config/v1/keybind" import type { DialogContext } from "../ui/dialog" const COMMAND_PALETTE_SHOW = "command.palette.show" diff --git a/packages/tui/src/plugin/runtime.tsx b/packages/tui/src/plugin/runtime.tsx index 4130ac9be7..ea2d88d84b 100644 --- a/packages/tui/src/plugin/runtime.tsx +++ b/packages/tui/src/plugin/runtime.tsx @@ -4,7 +4,7 @@ import type { TuiPluginInstallResult, TuiPluginStatus, } from "@opencode-ai/plugin/tui" -import type { TuiConfig } from "../config" +import type { TuiConfig } from "../config/v1" import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js" import { createPluginRoutes } from "./api" import { createSlots, type HostSlots } from "./slots" diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 7b01a2e9dc..0778ecc16f 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -1,7 +1,7 @@ import path from "path" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" -import type { SessionPromptInput } from "@opencode-ai/client/promise" +import type { SessionPromptInput } from "@opencode-ai/client" import type { Types } from "effect" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" @@ -82,16 +82,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create const current = store.history.at(store.index) if (!current) return undefined if (current.text !== input && input.length) return - setStore( - produce((draft) => { - const next = store.index + direction - if (Math.abs(next) > store.history.length) return - if (next > 0) return - draft.index = next - }), - ) - if (store.index === 0) return emptyPrompt() - return store.history.at(store.index) + const next = store.index + direction + if (Math.abs(next) > store.history.length || next > 0) return + setStore("index", next) + if (next === 0) return emptyPrompt() + return store.history.at(next) }, append(item: PromptInfo) { const entry = structuredClone(unwrap(item)) diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 459dbf944e..e6fc75dfb3 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -1,7 +1,6 @@ import { Prompt, type PromptRef } from "../component/prompt" import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Logo } from "../component/logo" -import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" import { useRouteData } from "../context/route" @@ -10,7 +9,7 @@ import { useLocal } from "../context/local" import { usePluginRuntime } from "../plugin/runtime" import { useEditorContext } from "../context/editor" import { useTerminalDimensions } from "@opentui/solid" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { HomeSessionDestinationProvider } from "./home/session-destination" import { useData } from "../context/data" import { LocationProvider } from "../context/location" @@ -24,7 +23,6 @@ const placeholder = { export function Home() { const pluginRuntime = usePluginRuntime() - const sync = useSync() const route = useRouteData("home") const promptRef = usePromptRef() const [ref, setRef] = createSignal() @@ -61,12 +59,12 @@ export function Home() { once = true } - // Wait for sync and model store to be ready before auto-submitting --prompt + // Wait for the model store to be ready before auto-submitting --prompt. createEffect(() => { const r = ref() if (sent) return if (!r) return - if (!sync.ready || !local.model.ready) return + if (!local.model.ready) return if (!args.prompt) return if (r.current.text !== args.prompt) return sent = true diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx index 35611b00b7..bf1e1c1db5 100644 --- a/packages/tui/src/routes/home/session-destination.tsx +++ b/packages/tui/src/routes/home/session-destination.tsx @@ -7,8 +7,8 @@ import { type ParentProps, type Setter, } from "solid-js" -import { useSync } from "../../context/sync" import { useTuiPaths } from "../../context/runtime" +import { useProject } from "../../context/project" export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } @@ -21,11 +21,11 @@ type Context = { const HomeSessionDestinationContext = createContext() export function HomeSessionDestinationProvider(props: ParentProps) { - const sync = useSync() + const project = useProject() const paths = useTuiPaths() const [selected, setDestination] = createSignal() const destination = createMemo( - () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false }, + () => selected() ?? { type: "directory", directory: project.instance.directory() || paths.cwd, subdirectory: false }, ) return ( @@ -121,32 +119,35 @@ export function Composer(props: ComposerProps) { paddingBottom={1} > - 1} - fallback={ - + + 1} + fallback={ {tabList()[0]?.label ?? ""} + } + > + + + {(t) => { + const isActive = createMemo(() => store.active === t.id) + return ( + + {t.label} + + ) + }} + - } - > - - - {(t) => { - const isActive = createMemo(() => store.active === t.id) - return ( - - {t.label} - - ) - }} - - - + + + esc + + @@ -168,12 +169,6 @@ export function Composer(props: ComposerProps) { ←/→ - - - close{" "} - - {closeHint()} - diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx index c6eb91594d..ed2a5c7f4f 100644 --- a/packages/tui/src/routes/session/dialog-fork.tsx +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, onMount, Show } from "solid-js" +import { unwrap } from "solid-js/store" import { useData } from "../../context/data" import { useRoute } from "../../context/route" import { useSDK } from "../../context/sdk" @@ -38,7 +39,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov description: file.description, mention: file.mention, })), - agents: structuredClone(message.agents ?? []), + agents: structuredClone(unwrap(message.agents ?? [])), pasted: [], } : undefined, diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index b3e162e9ad..c42e32139c 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-timeline.tsx @@ -1,7 +1,6 @@ import { createMemo, onMount } from "solid-js" -import { useSync } from "../../context/sync" +import { useData } from "../../context/data" import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" -import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" @@ -10,7 +9,7 @@ export function DialogTimeline(props: { sessionID: string onMove: (messageID: string) => void }) { - const sync = useSync() + const data = useData() const dialog = useDialog() onMount(() => { @@ -18,16 +17,12 @@ export function DialogTimeline(props: { }) const options = createMemo((): DialogSelectOption[] => { - const messages = sync.data.message[props.sessionID] ?? [] + const messages = data.session.message.list(props.sessionID) const result = [] as DialogSelectOption[] for (const message of messages) { - if (message.role !== "user") continue - const part = (sync.data.part[message.id] ?? []).find( - (x) => x.type === "text" && !x.synthetic && !x.ignored, - ) as TextPart - if (!part) continue + if (message.type !== "user") continue result.push({ - title: part.text.replace(/\n/g, " "), + title: message.text.replace(/\n/g, " "), value: message.id, footer: Locale.time(message.time.created), onSelect: (dialog) => { diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index d163f21477..856a29f0f8 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -1,6 +1,5 @@ import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { useTheme } from "../../context/theme" -import { useSync } from "../../context/sync" import { useData } from "../../context/data" import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/use-connected" @@ -9,15 +8,17 @@ import { useRoute } from "../../context/route" export function Footer() { const { theme } = useTheme() - const sync = useSync() const data = useData() const route = useRoute() - const mcp = createMemo(() => (data.location.mcp.list() ?? []).filter((x) => x.status.status === "connected").length) - const mcpError = createMemo(() => (data.location.mcp.list() ?? []).some((x) => x.status.status === "failed")) - const lsp = createMemo(() => Object.keys(sync.data.lsp)) + const mcp = createMemo( + () => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length, + ) + const mcpError = createMemo(() => + (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"), + ) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + return data.session.permission.list(route.data.sessionID) ?? [] }) const directory = useDirectory() const connected = useConnected() @@ -68,9 +69,6 @@ export function Footer() { {permissions().length > 1 ? "s" : ""} - - 0 ? theme.success : theme.textMuted }}>• {lsp().length} LSP - diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 8c7aec59c7..90f8a75d6e 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -4,13 +4,13 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import open from "open" import { selectedForeground, tint, useTheme } from "../../context/theme" -import type { FormField, FormValue } from "@opencode-ai/sdk/v2" +import type { FormField, FormValue } from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useSDK } from "../../context/sdk" import { useClipboard } from "../../context/clipboard" import { SplitBorder } from "../../ui/border" import { useToast } from "../../ui/toast" -import { useTuiConfig } from "../../config" +import { useTuiConfig } from "../../config/v1" import { useBindings, useOpencodeModeStack } from "../../keymap" const FORM_MODE = "form" diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 13c200101a..5e6fc8eaf3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -35,9 +35,10 @@ import type { SessionMessageAssistantTool, SessionMessageUser, SessionInfo, -} from "@opencode-ai/sdk/v2" +} from "@opencode-ai/client" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" +import { FilePath } from "../../ui/file-path" import { webSearchProviderLabel } from "../../util/tool-display" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "../../context/sdk" @@ -63,7 +64,7 @@ import { FormPrompt } from "./form" import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" -import { useTuiConfig } from "../../config" +import { useTuiConfig } from "../../config/v1" import { useClipboard } from "../../context/clipboard" import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" @@ -88,11 +89,8 @@ const sessionBindingCommands = [ "session.redo", "session.sidebar.toggle", "session.toggle.conceal", - "session.toggle.timestamps", "session.toggle.thinking", - "session.toggle.actions", "session.toggle.scrollbar", - "session.toggle.generic_tool_output", "session.toggle.exploration_grouping", "session.first", "session.last", @@ -126,9 +124,6 @@ const context = createContext<{ conceal: () => boolean thinkingMode: () => ThinkingMode showThinking: () => boolean - showTimestamps: () => boolean - showDetails: () => boolean - showGenericToolOutput: () => boolean groupExploration: () => boolean diffWrapMode: () => "word" | "none" models: () => ModelInfo[] @@ -159,12 +154,7 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) - const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) - const sessionMessages = () => - messageIDs().flatMap((id) => { - const message = data.session.message.get(route.sessionID, id) - return message ? [message] : [] - }) + const messages = () => data.session.message.list(route.sessionID) const location = createMemo(() => session()?.location) createEffect(() => { @@ -172,7 +162,6 @@ export function Session() { setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) }) onCleanup(() => setEpilogue()) - const messages = sessionMessages const descendantSessionIDs = createMemo(() => { if (session()?.parentID) return [] return data.session.family(route.sessionID).filter((id) => id !== route.sessionID) @@ -213,13 +202,9 @@ export function Session() { const thinking = useThinkingMode() const thinkingMode = thinking.mode const showThinking = createMemo(() => true) - const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") - const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) - const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) - const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true) const wide = createMemo(() => dimensions().width > 120) @@ -229,7 +214,6 @@ export function Session() { if (sidebar() === "auto" && wide()) return true return false }) - const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const models = createMemo(() => data.location.model.list(location()) ?? []) @@ -496,19 +480,6 @@ export function Session() { dialog.clear() }, }, - { - title: showTimestamps() ? "Hide timestamps" : "Show timestamps", - value: "session.toggle.timestamps", - category: "Session", - slash: { - name: "timestamps", - aliases: ["toggle-timestamps"], - }, - run: () => { - setTimestamps((prev) => (prev === "show" ? "hide" : "show")) - dialog.clear() - }, - }, { title: (() => { const next = nextThinkingMode(thinkingMode()) @@ -526,15 +497,6 @@ export function Session() { dialog.clear() }, }, - { - title: showDetails() ? "Hide tool details" : "Show tool details", - value: "session.toggle.actions", - category: "Session", - run: () => { - setShowDetails((prev) => !prev) - dialog.clear() - }, - }, { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", @@ -544,15 +506,6 @@ export function Session() { dialog.clear() }, }, - { - title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", - value: "session.toggle.generic_tool_output", - category: "Session", - run: () => { - setShowGenericToolOutput((prev) => !prev) - dialog.clear() - }, - }, { title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools", value: "session.toggle.exploration_grouping", @@ -648,7 +601,7 @@ export function Session() { category: "Session", hidden: true, run: () => { - const messages = sessionMessages() + const messages = data.session.message.list(route.sessionID) if (!messages || !messages.length) return // Find the most recent user message with non-ignored, non-synthetic text parts @@ -732,13 +685,7 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript( - sessionData, - messages(), - showThinking(), - showDetails(), - showAssistantMetadata(), - ) + const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -759,19 +706,13 @@ export function Session() { const sessionData = session() if (!sessionData) return - const options = await DialogExportOptions.show(dialog, showThinking(), showDetails(), showAssistantMetadata()) + const options = await DialogExportOptions.show(dialog, showThinking()) if (options === null) return const content = options.format === "markdown" - ? formatSessionTranscript( - sessionData, - messages(), - options.thinking, - options.toolDetails, - options.assistantMetadata, - ) + ? formatSessionTranscript(sessionData, messages(), options.thinking) : await (async () => { if (options.debug) { const events: { readonly created: number }[] = [] @@ -927,9 +868,6 @@ export function Session() { conceal, thinkingMode, showThinking, - showTimestamps, - showDetails, - showGenericToolOutput, groupExploration, diffWrapMode, models, @@ -1059,6 +997,9 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = {(message) => } )} + + + {(row) => } @@ -1304,15 +1245,43 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) { function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { const { theme } = useTheme() + const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined) + const completion = () => metadata()?.source === "subagent" + const state = () => stringValue(metadata()?.state) + const agent = () => Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent") const text = () => { if (props.message.type === "system") return props.message.text if (props.message.type === "synthetic") return props.message.description ?? "" return "" } + const status = () => { + if (state() === "completed") return "finished" + if (state() === "error") return "failed" + return state() ?? "finished" + } + const color = () => { + if (state() === "error") return theme.error + if (state() === "cancelled") return theme.warning + return theme.info + } return ( - - {text()} - + + {text()} + + } + > + + + + {state() === "completed" ? "↳" : "!"} {agent()} {status()} + + · {text()} + + + ) } @@ -1325,23 +1294,18 @@ function SessionSkillMessage(props: { message: Extract - status?: "running" - text?: string -}) { +function CompactionMessage(props: { message: Extract }) { const ctx = use() const kv = useKV() const { theme, syntax } = useTheme() - const status = () => props.message?.status ?? props.status - const text = () => - props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "") - const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted) - const border = color + const status = () => props.message.status + const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary) + const content = createMemo(() => text().trim()) + const color = () => (status() === "failed" ? theme.error : theme.textMuted) return ( - + @@ -1349,24 +1313,21 @@ function CompactionMessage(props: { - - - Compaction - + - + + + + + Compaction queued + + + + ) +} + function statusLabel(status: "added" | "modified" | "deleted") { if (status === "added") return "A" if (status === "deleted") return "D" @@ -1393,6 +1368,7 @@ function RevertMessage(props: { readonly deletions: number }> }) { + const ctx = use() const { theme } = useTheme() const route = useRouteData("session") const sdk = useSDK() @@ -1435,9 +1411,17 @@ function RevertMessage(props: { {(file) => ( {statusLabel(file.status)} - - {Locale.truncateLeft(file.file, 60)} - + 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) - + (file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0), + )} + fg={theme.text} + /> 0}> +{file.additions} @@ -1521,13 +1505,7 @@ function UserMessage(props: { message: SessionMessageUser }) { > {props.message.text} - + {(file) => { const label = file.mime === "application/x-directory" ? "dir" : "file" @@ -1544,11 +1522,6 @@ function UserMessage(props: { message: SessionMessageUser }) { - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - @@ -1852,31 +1825,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { // Pending messages moved to individual tool pending functions function ToolPart(props: { part: SessionMessageAssistantTool }) { - const ctx = use() - const data = useData() const display = createMemo(() => toolDisplay(props.part.name)) - const activeBackgroundWork = createMemo(() => { - if (props.part.state.status === "streaming") return false - if (display() === "shell") { - const shellID = stringValue(props.part.state.structured.shellID) - return Boolean(shellID && data.shell.get(shellID)) - } - if (display() === "subagent") { - const sessionID = - stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId) - return Boolean(sessionID && data.session.status(sessionID) === "running") - } - return false - }) - - // Hide tool if showDetails is false and tool completed successfully - const shouldHide = createMemo(() => { - if (ctx.showDetails()) return false - if (activeBackgroundWork()) return false - if (props.part.state.status !== "completed") return false - if (display() === "shell") return false - return true - }) const toolprops = { get metadata() { @@ -1900,52 +1849,50 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { } return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } @@ -1957,40 +1904,55 @@ type ToolProps = { part: SessionMessageAssistantTool } function GenericTool(props: ToolProps) { - const { theme } = useTheme() - const ctx = use() + const { theme, syntax } = useTheme() const output = createMemo(() => props.output?.trim() ?? "") + const args = createMemo(() => JSON.stringify(props.input, null, 2)) const [expanded, setExpanded] = createSignal(false) - const maxLines = 3 - const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) - const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) - const limited = createMemo(() => { - if (expanded() || !collapsed().overflow) return output() - return collapsed().output - }) + const expandable = createMemo(() => Object.keys(props.input).length > 0 || output().length > 0) return ( - - {props.tool} {input(props.input)} - - } + setExpanded((value) => !value) : undefined} > - setExpanded((prev) => !prev) : undefined} - > - - {limited()} - - {expanded() ? "Click to collapse" : "Click to expand"} + + + 0}> + + + Input + + + + + + + + {(value) => ( + + + Output + + + + {value()} + + + + )} - - + + ) } @@ -2093,12 +2055,7 @@ export function InlineToolRow(props: { - {props.pending} - } - when={props.complete || props.failed} - > + {props.pending}} when={props.complete || props.failed}> void part?: SessionMessageAssistantTool @@ -2162,18 +2120,41 @@ function BlockTool(props: { props.onClick?.() }} > - - {(title) => ( - - {title()} - - } - > - {title().replace(/^# /, "")} + + {(title) => ( + {title()}} + > + {title().replace(/^# /, "")} + + )} + } + > + {(path) => ( + + + {path().label} + + } + > + + {path().label.replace(/^# /, "")} + + + + )} {props.children} @@ -2268,7 +2249,10 @@ function Write(props: ToolProps) { return ( - + {(item) => ( - + @@ -2567,7 +2552,10 @@ function ApplyPatch(props: ToolProps) { {(file) => ( @@ -2601,10 +2589,17 @@ function ApplyPatch(props: ToolProps) { {(file) => ( - {file.resource} + )} @@ -2612,15 +2607,19 @@ function ApplyPatch(props: ToolProps) { @@ -2743,13 +2742,7 @@ function recordValue(value: unknown): Record | undefined { return value as Record } -function formatSessionTranscript( - session: SessionInfo, - messages: SessionMessageInfo[], - thinking: boolean, - toolDetails: boolean, - assistantMetadata: boolean, -) { +function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") @@ -2758,7 +2751,6 @@ function formatSessionTranscript( const content = message.content.flatMap((item) => { if (item.type === "text") return [item.text] if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : [] - if (!toolDetails) return [`**Tool: ${item.name}**`] const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2) const output = item.state.status === "error" @@ -2770,13 +2762,7 @@ function formatSessionTranscript( .join("\n") return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] }) - const duration = message.time.completed - ? ` · ${((message.time.completed - message.time.created) / 1000).toFixed(1)}s` - : "" - const heading = assistantMetadata - ? `## Assistant (${message.agent} · ${message.model.providerID}/${message.model.id}${duration})` - : "## Assistant" - return [`${heading}\n\n${content.join("\n\n")}`] + return [`## Assistant\n\n${content.join("\n\n")}`] }) return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n` } diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a0ca4775c3..51553a97d9 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -4,7 +4,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js" import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useTheme, selectedForeground } from "../../context/theme" -import type { PermissionV2Request } from "@opencode-ai/sdk/v2" +import type { PermissionV2Request } from "@opencode-ai/client" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" import { useData } from "../../context/data" @@ -12,7 +12,7 @@ import { filetype } from "../../util/filetype" import { Locale } from "../../util/locale" import { webSearchProviderLabel } from "../../util/tool-display" import { getScrollAcceleration } from "../../util/scroll" -import { useTuiConfig } from "../../config" +import { useTuiConfig } from "../../config/v1" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index a148f9fd20..1df58d3539 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,4 +1,4 @@ -import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useData } from "../../context/data" @@ -10,6 +10,7 @@ export type PartRef = { export type SessionRow = | { type: "message"; messageID: string } + | { type: "compaction-queued"; inputID: string } | { type: "part"; ref: PartRef } | { type: "group" @@ -31,6 +32,14 @@ export function createSessionRows(sessionID: Accessor) { const boundary = revertBoundary() const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) partitionPending(rows, pendingPermissions()) + const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) + rows.splice( + position === -1 ? rows.length : position, + 0, + ...data.session.compaction + .list(sessionID()) + .map((inputID): SessionRow => ({ type: "compaction-queued", inputID })), + ) return rows } @@ -54,6 +63,7 @@ export function createSessionRows(sessionID: Accessor) { createEffect( on(sessionID, (id) => { setRows(reconcile(reduce())) + void data.session.compaction.refresh(id).catch(() => undefined) void data.session.message.refresh(id).then( () => { if (sessionID() !== id) return @@ -71,6 +81,13 @@ export function createSessionRows(sessionID: Accessor) { }), ) + createEffect( + on( + () => data.session.compaction.list(sessionID()).map((inputID) => inputID), + () => setRows(reconcile(reduce())), + ), + ) + createEffect( on( () => @@ -88,7 +105,6 @@ export function createSessionRows(sessionID: Accessor) { { id: message.id, created: message.time.created, - input: message.status === "running", }, ] : [], @@ -183,7 +199,9 @@ export function createSessionRows(sessionID: Accessor) { } const subscriptions = [ data.on("session.input.admitted", input), - data.on("session.compaction.started", message), + data.on("session.compaction.started", (event) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_")) + }), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -192,9 +210,6 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.shell.started", message), data.on("session.agent.selected", message), data.on("session.model.selected", message), - data.on("session.compaction.ended", (event) => { - if (event.data.reason !== "manual") message(event) - }), data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index bd415768a2..b1f7744b9a 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -1,26 +1,18 @@ -import { useProject } from "../../context/project" import { useData } from "../../context/data" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" -import { useTuiConfig } from "../../config" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { useTuiConfig } from "../../config/v1" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { usePluginRuntime } from "../../plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" -import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const pluginRuntime = usePluginRuntime() - const project = useProject() const data = useData() const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => data.session.get(props.sessionID)) - const workspace = () => { - const workspaceID = session()?.location.workspaceID - if (!workspaceID) return - return project.workspace.get(workspaceID) - } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) return ( @@ -56,26 +48,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} - - {props.sessionID} - - - } - > - {(item) => ( - - )} - + {session()!.location.workspaceID} diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index f02ef3f20e..2d32055e4f 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -6,7 +6,12 @@ import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" -import { lastAssistantWithUsage } from "../../util/session" +import { contextUsage } from "../../util/session" + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) export function SubagentFooter() { const route = useRouteData("session") @@ -23,26 +28,21 @@ export function SubagentFooter() { const usage = createMemo(() => { const current = session() if (!current) return - const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID) - if (!last) return - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - if (tokens <= 0) return - - const model = data.location - .model.list(current.location) - ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) - const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = current.cost - - const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }) + const formattedCost = cost > 0 ? money.format(cost) : undefined + const context = contextUsage( + data.session.message.list(route.sessionID), + data.location.model.list(current.location), + current.revert?.messageID, + ) return { - context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), - cost: cost > 0 ? money.format(cost) : undefined, + context: context + ? context.percent === undefined + ? Locale.number(context.tokens) + : `${Locale.number(context.tokens)} (${context.percent}%)` + : undefined, + cost: formattedCost, } }) diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index 914d0960c8..db05529158 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -9,20 +9,11 @@ export type ExportFormat = "markdown" | "json" export type DialogExportOptionsProps = { defaultThinking: boolean - defaultToolDetails: boolean - defaultAssistantMetadata: boolean - onConfirm?: (options: { - action: "copy" | "export" - format: ExportFormat - debug: boolean - thinking: boolean - toolDetails: boolean - assistantMetadata: boolean - }) => void + onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; debug: boolean; thinking: boolean }) => void onCancel?: () => void } -type Active = ExportFormat | "debug" | "thinking" | "toolDetails" | "assistantMetadata" | "copy" | "export" +type Active = ExportFormat | "debug" | "thinking" | "copy" | "export" export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() @@ -31,8 +22,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { format: "markdown" as ExportFormat, debug: false, thinking: props.defaultThinking, - toolDetails: props.defaultToolDetails, - assistantMetadata: props.defaultAssistantMetadata, active: "markdown" as Active, }) @@ -42,8 +31,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { format: store.format, debug: store.debug, thinking: store.thinking, - toolDetails: store.toolDetails, - assistantMetadata: store.assistantMetadata, }) const activate = () => { @@ -53,8 +40,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { } if (store.active === "debug") setStore("debug", !store.debug) if (store.active === "thinking") setStore("thinking", !store.thinking) - if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) - if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) if (store.active === "copy" || store.active === "export") confirm(store.active) } @@ -67,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { cmd: () => { const order: Active[] = store.format === "markdown" - ? ["markdown", "json", "thinking", "toolDetails", "assistantMetadata", "copy", "export"] + ? ["markdown", "json", "thinking", "copy", "export"] : ["markdown", "json", "debug", "copy", "export"] setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, @@ -86,11 +71,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { setStore("active", format) } - const toggle = (option: "thinking" | "toolDetails" | "assistantMetadata") => { - setStore("active", option) - setStore(option, !store[option]) - } - return ( @@ -121,30 +101,19 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { - - - {(item) => ( - toggle(item[0])} - > - - {store[item[0]] ? "[x]" : "[ ]"} - - {item[1]} - - )} - + { + setStore("active", "thinking") + setStore("thinking", !store.thinking) + }} + > + + {store.thinking ? "[x]" : "[ ]"} + + Include thinking @@ -170,12 +139,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { > Copy - confirm("export")} - > + confirm("export")}> Export @@ -183,26 +147,17 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { ) } -DialogExportOptions.show = ( - dialog: DialogContext, - defaultThinking: boolean, - defaultToolDetails: boolean, - defaultAssistantMetadata: boolean, -) => { +DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) => { return new Promise<{ action: "copy" | "export" format: ExportFormat debug: boolean thinking: boolean - toolDetails: boolean - assistantMetadata: boolean } | null>((resolve) => { dialog.replace( () => ( resolve(options)} onCancel={() => resolve(null)} /> diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index f518fb2950..0898b0ae42 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { useBindings, useCommandShortcut } from "../keymap" export type DialogPromptProps = { diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 28235531e2..ef13bc1d50 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -17,7 +17,7 @@ import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "./dialog" import { Locale } from "../util/locale" import { getScrollAcceleration } from "../util/scroll" -import { useTuiConfig } from "../config" +import { useTuiConfig } from "../config/v1" import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { diff --git a/packages/tui/src/ui/file-path.tsx b/packages/tui/src/ui/file-path.tsx new file mode 100644 index 0000000000..0633e2aef4 --- /dev/null +++ b/packages/tui/src/ui/file-path.tsx @@ -0,0 +1,106 @@ +import type { RGBA } from "@opentui/core" +import { createMemo } from "solid-js" + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +export interface FilePathProps { + value: string + maxWidth: number + fg?: RGBA + basenameFg?: RGBA +} + +export function FilePath(props: FilePathProps) { + const display = createMemo(() => { + const value = truncateFilePath(props.value, props.maxWidth) + const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + return { + parent: value.slice(0, index + 1), + basename: value.slice(index + 1), + } + }) + return ( + + {display().parent} + {display().basename} + + ) +} + +export function truncateFilePath(value: string, maxWidth: number) { + if (maxWidth <= 0) return "" + if (Bun.stringWidth(value) <= maxWidth) return value + + const drive = value.match(/^([A-Za-z]:)([\\/])/) + const unc = value.match(/^(\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/) + const windows = drive !== null || unc !== null || (!value.includes("/") && value.includes("\\")) + const separator = drive?.[2] ?? (unc?.[1] === "//" ? "/" : windows ? "\\" : "/") + const root = drive + ? drive[1] + separator + : unc + ? unc[1] + unc[2] + separator + unc[3] + separator + : value.startsWith("/") + ? "/" + : "" + const source = value.slice(drive?.[0].length ?? unc?.[0].length ?? root.length) + const segments = source.split(windows ? /[\\/]/ : separator).filter(Boolean) + const basename = segments.at(-1) ?? value + if (segments.length < 2) { + const rootWidth = Bun.stringWidth(root) + if (rootWidth >= maxWidth) return takeStart(root, maxWidth) + return root + truncateBasename(basename, maxWidth - rootWidth) + } + + const prefix = `${root}…${separator}` + const basenameWidth = maxWidth - Bun.stringWidth(prefix) + if (basenameWidth <= 0) return takeStart(prefix, maxWidth) + const compact = truncateBasename(basename, basenameWidth) + if (compact !== basename) return prefix + compact + + const selected = [basename] + const separatorWidth = Bun.stringWidth(separator) + let width = Bun.stringWidth(prefix + basename) + for (let index = segments.length - 2; index >= 0; index--) { + const next = Bun.stringWidth(segments[index]!) + separatorWidth + if (width + next > maxWidth) break + selected.unshift(segments[index]!) + width += next + } + return prefix + selected.join(separator) +} + +function truncateBasename(value: string, maxWidth: number) { + if (Bun.stringWidth(value) <= maxWidth) return value + if (maxWidth <= 1) return takeStart("…", maxWidth) + + const dot = value.lastIndexOf(".") + const extension = dot > 0 ? value.slice(dot) : "" + const extensionWidth = Bun.stringWidth(extension) + if (extensionWidth >= maxWidth) return "…" + takeEnd(extension, maxWidth - 1) + + const stem = extension ? value.slice(0, dot) : value + return takeStart(stem, maxWidth - extensionWidth - 1) + "…" + extension +} + +function takeStart(value: string, maxWidth: number) { + return take(value, maxWidth, false) +} + +function takeEnd(value: string, maxWidth: number) { + return take(value, maxWidth, true) +} + +function take(value: string, maxWidth: number, reverse: boolean) { + const segments = Array.from(graphemeSegmenter.segment(value), (item) => item.segment) + if (reverse) segments.reverse() + const selected: string[] = [] + let width = 0 + for (const segment of segments) { + const next = Bun.stringWidth(segment) + if (width + next > maxWidth) break + selected.push(segment) + width += next + } + if (reverse) selected.reverse() + return selected.join("") +} diff --git a/packages/tui/src/util/connected-provider.ts b/packages/tui/src/util/connected-provider.ts index 4ceacf6552..2fbb9f718e 100644 --- a/packages/tui/src/util/connected-provider.ts +++ b/packages/tui/src/util/connected-provider.ts @@ -1,4 +1,4 @@ -import type { IntegrationInfo } from "@opencode-ai/sdk/v2" +import type { IntegrationInfo } from "@opencode-ai/client" export function hasConnectedProvider(integrations: readonly Pick[]) { return integrations.some((integration) => integration.connections.length > 0) diff --git a/packages/tui/src/util/model.ts b/packages/tui/src/util/model.ts index 275112d33d..215602845f 100644 --- a/packages/tui/src/util/model.ts +++ b/packages/tui/src/util/model.ts @@ -1,32 +1,8 @@ -import type { Provider } from "@opencode-ai/sdk/v2" - export function parse(value: string) { const [providerID, ...modelID] = value.split("/") return { providerID, modelID: modelID.join("/") } } -export function index(list: Provider[] | undefined) { - return new Map((list ?? []).map((item) => [item.id, item] as const)) -} - -export function get(list: Provider[] | ReadonlyMap | undefined, providerID: string, modelID: string) { - const provider = - list instanceof Map - ? list.get(providerID) - : Array.isArray(list) - ? list.find((item) => item.id === providerID) - : undefined - return provider?.models[modelID] -} - -export function name( - list: Provider[] | ReadonlyMap | undefined, - providerID: string, - modelID: string, -) { - return get(list, providerID, modelID)?.name ?? modelID -} - export function formatRef(model: { providerID: string; id: string; variant?: string }) { return [model.providerID, model.id, model.variant].filter((value) => value !== undefined).join("/") } diff --git a/packages/tui/src/util/provider-origin.ts b/packages/tui/src/util/provider-origin.ts deleted file mode 100644 index 48d1f852de..0000000000 --- a/packages/tui/src/util/provider-origin.ts +++ /dev/null @@ -1,7 +0,0 @@ -const contains = (consoleManagedProviders: string[] | ReadonlySet, providerID: string) => - Array.isArray(consoleManagedProviders) - ? consoleManagedProviders.includes(providerID) - : consoleManagedProviders.has(providerID) - -export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet, providerID: string) => - contains(consoleManagedProviders, providerID) diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 41f583b1c6..7fe11fb7db 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,4 +1,4 @@ -import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" +import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) @@ -7,8 +7,29 @@ export function isDefaultTitle(title: string) { export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 if (boundary && boundaryIndex === -1) return undefined + const end = boundaryIndex === -1 ? messages.length : boundaryIndex + const compactionIndex = messages.findLastIndex( + (message, index) => message.type === "compaction" && message.status === "completed" && index < end, + ) return messages.findLast( (message, index): message is SessionMessageAssistant & { tokens: NonNullable } => - message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex), + message.type === "assistant" && message.tokens !== undefined && index > compactionIndex && index < end, ) } + +export function contextUsage( + messages: ReadonlyArray, + models: ReadonlyArray | undefined, + boundary?: string, +) { + const last = lastAssistantWithUsage(messages, boundary) + if (!last) return + const tokens = + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + if (tokens <= 0) return + const model = models?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) + return { + tokens, + percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined, + } +} diff --git a/packages/tui/src/util/transcript.ts b/packages/tui/src/util/transcript.ts deleted file mode 100644 index d727c19fb8..0000000000 --- a/packages/tui/src/util/transcript.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" -import { Locale } from "./locale" -import * as Model from "./model" - -export type TranscriptOptions = { - thinking: boolean - toolDetails: boolean - assistantMetadata: boolean - providers?: Provider[] -} - -export type SessionInfo = { - id: string - title: string - time: { - created: number - updated: number - } -} - -export type MessageWithParts = { - info: UserMessage | AssistantMessage - parts: Part[] -} - -export function formatTranscript( - session: SessionInfo, - messages: MessageWithParts[], - options: TranscriptOptions, -): string { - const providers = Model.index(options.providers) - let transcript = `# ${session.title}\n\n` - transcript += `**Session ID:** ${session.id}\n` - transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` - transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n` - transcript += `---\n\n` - - for (const msg of messages) { - transcript += formatMessage(msg.info, msg.parts, options, providers) - transcript += `---\n\n` - } - - return transcript -} - -export function formatMessage( - msg: UserMessage | AssistantMessage, - parts: Part[], - options: TranscriptOptions, - providers?: Provider[] | ReadonlyMap, -): string { - let result = "" - - if (msg.role === "user") { - result += `## User\n\n` - } else { - result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers) - } - - for (const part of parts) { - result += formatPart(part, options) - } - - return result -} - -export function formatAssistantHeader( - msg: AssistantMessage, - includeMetadata: boolean, - providers?: Provider[] | ReadonlyMap, -): string { - if (!includeMetadata) { - return `## Assistant\n\n` - } - - const duration = - msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" - - const modelName = Model.name(providers, msg.providerID, msg.modelID) - - return `## Assistant (${Locale.titlecase(msg.agent)} · ${modelName}${duration ? ` · ${duration}` : ""})\n\n` -} - -export function formatPart(part: Part, options: TranscriptOptions): string { - if (part.type === "text" && !part.synthetic) { - return `${part.text}\n\n` - } - - if (part.type === "reasoning") { - if (options.thinking) { - return `_Thinking:_\n\n${part.text}\n\n` - } - return "" - } - - if (part.type === "tool") { - let result = `**Tool: ${part.tool}**\n` - if (options.toolDetails && part.state.input) { - result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n` - } - if (options.toolDetails && part.state.status === "completed" && part.state.output) { - result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n` - } - if (options.toolDetails && part.state.status === "error" && part.state.error) { - result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n` - } - result += `\n` - return result - } - - return "" -} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index c1530ee189..48d4a48aba 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -96,6 +96,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after }) if (url.pathname === "/api/session/dummy") return json({ data: session }) if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) diff --git a/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts deleted file mode 100644 index 17e7090cf3..0000000000 --- a/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { recentConnectedWorkspaces } from "../../../../src/component/dialog-workspace-create" - -describe("recentConnectedWorkspaces", () => { - test("returns connected workspaces sorted by time used", () => { - const workspaces = [ - { id: "wrk_a", name: "alpha", timeUsed: 700 }, - { id: "wrk_b", name: "beta", timeUsed: 800 }, - { id: "wrk_c", name: "gamma", timeUsed: 400 }, - { id: "wrk_d", name: "delta", timeUsed: 300 }, - { id: "wrk_e", name: "epsilon", timeUsed: 200 }, - ] - const status = { - wrk_a: "connected", - wrk_b: "disconnected", - wrk_c: "error", - wrk_d: "connected", - wrk_e: "connected", - } as const - - const { recent } = recentConnectedWorkspaces({ - workspaces, - status: (workspaceID) => status[workspaceID as keyof typeof status], - }) - - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) - }) -}) diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts index f9cc482c6f..a07788248b 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { IntegrationInfo } from "@opencode-ai/sdk/v2" +import type { IntegrationInfo } from "@opencode-ai/client" import { connectionSummary, connectMethods, diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index a5931c4481..6f8399fbdd 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import Notifications from "../../../../src/feature-plugins/system/notifications" -import type { OpenCodeEvent } from "@opencode-ai/client/promise" -import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2" +import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client" +import type { Session } from "@opencode-ai/sdk/v2" import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui" import { createTuiPluginApi } from "../../../fixture/tui-plugin" @@ -69,7 +69,7 @@ async function setup() { } } -function question(id: string, sessionID = "session"): QuestionRequest { +function question(id: string, sessionID = "session"): QuestionAsked["data"] { return { id, sessionID, @@ -86,7 +86,7 @@ function form(id: string, sessionID = "session"): Extract { - test("includes a synthetic Other option for custom providers", () => { - expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({ - title: "Other", - description: "Custom provider", - category: "Providers", - }) - }) - - test("does not use Other as the generic provider category", () => { - expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers") - }) - - test("keeps popular providers first and sorts the rest alphabetically", () => { - expect( - providerOptions([ - { id: "openai", name: "OpenAI" }, - { id: "custom-z", name: "Zebra Provider" }, - { id: "anthropic", name: "Anthropic" }, - { id: "mistral", name: "Mistral" }, - { id: "aws", name: "AWS Bedrock" }, - ]).map((option) => option.value), - ).toEqual(["openai", "anthropic", "aws", "mistral", "custom-z", "__opencode_custom_provider__"]) - }) - - test("does not collide with a configured provider named other", () => { - const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value) - expect(new Set(values).size).toBe(values.length) - }) - - test("normalizes and validates custom provider ids", () => { - expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider") - expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider") - expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider") - expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined() - expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined() - }) -}) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index fade50e722..416085bdb4 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import type { OpenCodeEvent } from "@opencode-ai/client" import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { onMount } from "solid-js" @@ -111,7 +111,7 @@ test("refreshes resources into reactive getters", async () => { await data.location.agent.refresh() expect(data.session.get("ses_test")?.title).toBe("Test session") - expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"]) + expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"]) expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second") await app.renderOnce() expect(app.captureCharFrame()).toContain("msg_second") @@ -330,9 +330,9 @@ test("truncates committed revert messages without changing lifetime usage", asyn durable: durable(sessionID, 6), data: { sessionID, to: "msg_revert_later" }, }) - await wait(() => data.session.message.ids(sessionID).length === 1) + await wait(() => data.session.message.list(sessionID).length === 1) expect(data.session.get(sessionID)?.cost).toBe(0.75) - expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"]) + expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_revert_boundary"]) expect(data.session.get(sessionID)?.revert).toBeUndefined() expect(data.session.get(sessionID)?.tokens).toEqual(tokens) } finally { @@ -688,7 +688,7 @@ test("removes committed revert messages from local state", async () => { data: { sessionID, inputID, input: { type: "user", data: { text: inputID }, delivery: "steer" } }, }) } - await wait(() => data.session.message.ids(sessionID).length === 3) + await wait(() => data.session.message.list(sessionID).length === 3) emitEvent(events, { id: EventV2.ID.create(), @@ -698,8 +698,8 @@ test("removes committed revert messages from local state", async () => { data: { sessionID, to: "msg_002" }, }) - await wait(() => data.session.message.ids(sessionID).length === 1) - expect(data.session.message.ids(sessionID)).toEqual(["msg_001"]) + await wait(() => data.session.message.list(sessionID).length === 1) + expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_001"]) expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined() expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined() } finally { @@ -1047,6 +1047,14 @@ test("tracks session status from active sessions and execution events", async () await wait(() => data.session.status("session-retry") === "idle") expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry") + emitEvent(events, { + id: "evt_manual_compaction_admitted", + created: 0, + type: "session.compaction.admitted", + durable: durable("session-manual", 1), + data: { sessionID: "session-manual", inputID: "message-compaction" }, + }) + await wait(() => data.session.compaction.list("session-manual").includes("message-compaction")) emitEvent(events, { id: "evt_manual_compaction_started", created: 1, @@ -1064,6 +1072,10 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-manual", "message-compaction") return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) + expect(data.session.compaction.list("session-manual")).toEqual([]) + const compactionRow = manualRows.find( + (row) => row.type === "message" && row.messageID === "message-compaction", + ) emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, @@ -1078,6 +1090,9 @@ test("tracks session status from active sessions and execution events", async () expect(manualRows.filter((row) => row.type === "message")).toEqual([ { type: "message", messageID: "message-compaction" }, ]) + expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe( + compactionRow, + ) emitEvent(events, { id: "evt_compaction_started", @@ -1102,6 +1117,9 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-live", "msg_compaction_started") return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" }) + const autoCompactionRow = rows.find( + (row) => row.type === "message" && row.messageID === "msg_compaction_started", + ) emitEvent(events, { id: "evt_compaction_ended", @@ -1119,6 +1137,102 @@ test("tracks session status from active sessions and execution events", async () status: "completed", summary: "Live summary", }) + expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe( + autoCompactionRow, + ) + expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse() + } finally { + app.renderer.destroy() + } +}) + +test("restores queued compaction from durable pending input", async () => { + const events = createEventStream() + const sessionID = "session-compaction-queued" + let pending = [ + { + admittedSeq: 3, + id: "message-compaction-queued", + sessionID, + timeCreated: 1, + type: "compaction" as const, + }, + { + admittedSeq: 4, + id: "message-compaction-later", + sessionID, + timeCreated: 2, + type: "compaction" as const, + }, + ] + const calls = createFetch((url) => { + if (url.pathname !== `/api/session/${sessionID}/pending`) return + return json({ data: pending }) + }, events) + let data!: ReturnType + let rows!: ReturnType + + function Probe() { + data = useData() + rows = createSessionRows(() => sessionID) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.compaction.list(sessionID).length === 2) + expect(data.session.compaction.list(sessionID)).toEqual([ + "message-compaction-queued", + "message-compaction-later", + ]) + await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2) + expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([ + { type: "compaction-queued", inputID: "message-compaction-queued" }, + { type: "compaction-queued", inputID: "message-compaction-later" }, + ]) + + emitEvent(events, { + id: "evt_compaction_started", + created: 2, + type: "session.compaction.started", + durable: durable(sessionID, 4), + data: { + sessionID, + reason: "manual", + recent: "", + inputID: "message-compaction-queued", + }, + }) + await wait(() => data.session.compaction.list(sessionID).length === 1) + expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + + emitEvent(events, { + id: "evt_compaction_ended", + created: 3, + type: "session.compaction.ended", + durable: durable(sessionID, 5), + data: { sessionID, reason: "manual", text: "Summary", recent: "" }, + }) + expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + + pending = [] + emitEvent(events, { + id: "evt_reconnected", + type: "server.connected", + data: {}, + }) + await wait(() => data.session.compaction.list(sessionID).length === 0) } finally { app.renderer.destroy() } @@ -1191,6 +1305,70 @@ test("refreshes integrations after integration updates", async () => { } }) +test("refreshes MCP resources after catalog updates", async () => { + const events = createEventStream() + let requests = 0 + const calls = createFetch((url) => { + if (url.pathname !== "/api/mcp/resource") return + requests++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: { + resources: + requests === 1 + ? [] + : [{ server: "docs", name: "API reference", uri: "https://example.com/api", description: "API docs" }], + templates: [], + }, + }) + }, events) + let data!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await wait(() => data.location.mcp.resource.list() !== undefined) + expect(data.location.mcp.resource.list()).toEqual([]) + + emitEvent(events, { + id: "evt_mcp_resources", + created: 0, + type: "mcp.resources.changed", + data: { server: "docs" }, + }) + await wait(() => data.location.mcp.resource.list()?.length === 1) + expect(data.location.mcp.resource.list()?.[0]).toEqual({ + server: "docs", + name: "API reference", + uri: "https://example.com/api", + description: "API docs", + }) + } finally { + app.renderer.destroy() + } +}) + test("refreshes effective catalog data after catalog updates", async () => { const events = createEventStream() const requests = { model: 0, provider: 0 } @@ -2061,6 +2239,17 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn const admitted = sync.session.message.list(sessionID)?.[0] expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" }) expect(admitted?.metadata).toBeUndefined() + expect(sync.session.pending.list(sessionID)).toEqual([ + { + id: messageID, + sessionID, + admittedSeq: 0, + timeCreated: 0, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }, + ]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) await sync.session.message.refresh(sessionID) @@ -2085,9 +2274,10 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn if (message?.type !== "user") return expect(message).toMatchObject({ id: messageID, text: "hello" }) expect(message.metadata).toBeUndefined() + expect(sync.session.pending.list(sessionID)).toEqual([]) expect(sync.session.input.list(sessionID)).toEqual([]) - expect(sync.session.message.ids(sessionID)).toEqual([messageID]) - expect(sync.session.message.ids("missing")).toEqual([]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + expect(sync.session.message.list("missing")).toEqual([]) expect(sync.session.message.get(sessionID, messageID)).toBe(message) expect(sync.session.message.get(sessionID, "missing")).toBeUndefined() expect(received).toHaveLength(3) @@ -2148,12 +2338,12 @@ test("projects live instruction updates with their message ID", async () => { } }) -function sessionInfo(id: string, parentID: string | undefined) { +function sessionInfo(id: string, parentID: string | undefined, cost = 0) { return { id, parentID, projectID: "proj_test", - cost: 0, + cost, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 }, title: id, @@ -2164,10 +2354,11 @@ function sessionInfo(id: string, parentID: string | undefined) { // Mounts a DataProvider whose `/api/session/:id` responses are driven by the // given parent map (sessionID -> parentID). Roots omit the entry. Reused across // the family-index tests below. -async function mountData(parents: Record) { +async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]]) }) + if (match && match[1] !== "active") + return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2235,6 +2426,24 @@ test("indexes arbitrarily deep nesting under a single root", async () => { } }) +test("totals family cost for roots and keeps subagent cost scoped", async () => { + const { data, app } = await mountData( + { grandchild: "child", child: "root" }, + { root: 1, child: 2, grandchild: 3 }, + ) + try { + await data.session.refresh("grandchild") + await data.session.refresh("child") + await data.session.refresh("root") + + expect(data.session.cost("root")).toBe(6) + expect(data.session.cost("child")).toBe(2) + expect(data.session.cost("grandchild")).toBe(3) + } finally { + app.renderer.destroy() + } +}) + test("re-registering an existing session is idempotent", async () => { const { data, app } = await mountData({ grandchild: "child", child: "root" }) try { diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index cca8f321e2..160f660e38 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -8,7 +8,7 @@ import path from "node:path" import { onCleanup } from "solid-js" import { tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import type { TuiKeybind } from "../../../src/config/keybind" +import type { TuiKeybind } from "../../../src/config/v1/keybind" import { TestTuiContexts } from "../../fixture/tui-environment" async function wait(fn: () => boolean, timeout = 2000) { @@ -41,7 +41,7 @@ async function mountPrompt(input: { import("../../../src/ui/dialog-prompt"), import("../../../src/context/kv"), import("../../../src/context/theme"), - import("../../../src/config"), + import("../../../src/config/v1"), import("../../../src/ui/toast"), import("../../../src/keymap"), ]) diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index 2a5a172f9c..3c6fc8a84a 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -6,7 +6,7 @@ import type { JSX } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { KVProvider } from "../../../src/context/kv" import { ThemeProvider } from "../../../src/context/theme" -import { TuiConfigProvider } from "../../../src/config" +import { TuiConfigProvider } from "../../../src/config/v1" import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree" import { TestTuiContexts } from "../../fixture/tui-environment" import { diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index f7fc9c0a51..3b77f7ed21 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -7,22 +7,28 @@ import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } import type { Session } from "@opencode-ai/sdk/v2" import { KVProvider } from "../../../src/context/kv" import { ThemeProvider } from "../../../src/context/theme" -import { TuiConfigProvider } from "../../../src/config" -import { TuiKeybind } from "../../../src/config/keybind" +import { TuiConfigProvider } from "../../../src/config/v1" +import { SDKProvider } from "../../../src/context/sdk" +import { TuiKeybind } from "../../../src/config/v1/keybind" import { OpencodeKeymapProvider } from "../../../src/keymap" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" +import { createApi, createClient, createEventStream, createFetch, json } from "../../fixture/tui-sdk" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) try { expect(viewer.current()).toEqual({ name: "diff", - params: { mode: "git", sessionID: "session-1", returnRoute: startRoute }, + params: { mode: "working", sessionID: "session-1", returnRoute: startRoute }, + }) + expect(viewer.vcsDiffInput()).toEqual({ + location: { directory: "/repo/session" }, + mode: "working", + context: "12", }) - expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 }) expect(viewer.commands.has("diff.close")).toBe(true) viewer.commands.get("diff.close")!.run?.({} as never) @@ -108,6 +114,18 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: let vcsDiffInput: unknown let sessionDiffInput: unknown const config = createTuiResolvedConfig() + const transport = createFetch((url) => { + if (url.pathname !== "/api/vcs/diff") return + vcsDiffInput = { + location: { directory: url.searchParams.get("location[directory]") }, + mode: url.searchParams.get("mode"), + context: url.searchParams.get("context"), + } + return json({ + location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } }, + data: vcsDiff, + }) + }, createEventStream()) function Harness() { const renderer = useRenderer() const keymap = createDefaultOpenTuiKeymap(renderer) @@ -119,12 +137,6 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: const base = createTuiPluginApi({ keymap, client: { - vcs: { - diff: async (input: unknown) => { - vcsDiffInput = input - return { data: vcsDiff } - }, - }, session: { diff: async (input: unknown) => { sessionDiffInput = input @@ -159,15 +171,17 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: return ( - - - - - {renderDiff?.({ params: "params" in current ? current.params : undefined })} - - - - + + + + + + {renderDiff?.({ params: "params" in current ? current.params : undefined })} + + + + + ) } @@ -218,7 +232,11 @@ test("branch diff source requests branch VCS diff", async () => { name: "diff", params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, }) - expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "branch", context: 12 }) + expect(viewer.vcsDiffInput()).toEqual({ + location: { directory: "/repo/session" }, + mode: "branch", + context: "12", + }) expect(viewer.sessionDiffInput()).toBeUndefined() } finally { viewer.app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index b447e3ab43..e73ef9973d 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -10,7 +10,7 @@ import type { FormWithLocation } from "../../../src/context/data" import { KVProvider } from "../../../src/context/kv" import { SDKProvider } from "../../../src/context/sdk" import { ThemeProvider } from "../../../src/context/theme" -import { TuiConfigProvider } from "../../../src/config" +import { TuiConfigProvider } from "../../../src/config/v1" import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap" import { ToastProvider } from "../../../src/ui/toast" import { tmpdir } from "../../fixture/fixture" diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c43a6faf9d..f6d2701ac7 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { reduceSessionRows } from "../../../src/routes/session/rows" test("groups exploration parts across assistant messages until a delimiter", () => { diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 04ae689b89..94194b122a 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client/promise" +import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { testRender } from "@opentui/solid" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { onMount } from "solid-js" @@ -9,7 +9,7 @@ import { SDKProvider, useSDK } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" -import type { LogSink } from "../../../src/context/log" +import type { LogLevel, LogSink } from "../../../src/context/log" const projectID = "proj_test" @@ -113,9 +113,9 @@ function Probe(props: { describe("useEvent", () => { test("logs only durable events", async () => { - const logs: Array<{ message: string; tags: Readonly> }> = [] - const { app, emit, seen } = await mount(undefined, (_level, message, tags) => { - if (message === "event") logs.push({ message, tags }) + const logs: Array<{ level: LogLevel; message: string; tags: Readonly> }> = [] + const { app, emit, seen } = await mount(undefined, (level, message, tags) => { + if (message === "event") logs.push({ level, message, tags }) }) const durable = event( { @@ -135,6 +135,7 @@ describe("useEvent", () => { expect(logs).toEqual([ { + level: "debug", message: "event", tags: { component: "sdk", type: "session.renamed", aggregateID: "ses_test", seq: 1 }, }, diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 82d7cf9338..96f7430d53 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -11,7 +11,7 @@ import { TuiConfigProvider, type Info as TuiConfigInfo, useTuiConfig, -} from "../src/config" +} from "../src/config/v1" const decodeInfo = Schema.decodeUnknownSync(Info) const decodePlugin = Schema.decodeUnknownSync(PluginSpec) @@ -86,6 +86,12 @@ test("resolves a session move keybind", () => { expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }]) }) +test("opens the subagent picker with down", () => { + const config = resolve({}, { terminalSuspend: true }) + + expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,down" }]) +}) + test("disables suspend and assigns ctrl+z to undo when unsupported", () => { const config = resolve({}, { terminalSuspend: false }) diff --git a/packages/tui/test/fixture/tui-runtime.ts b/packages/tui/test/fixture/tui-runtime.ts index 6878c08c60..028396c57d 100644 --- a/packages/tui/test/fixture/tui-runtime.ts +++ b/packages/tui/test/fixture/tui-runtime.ts @@ -1,5 +1,5 @@ -import { resolve, type Info, type Resolved } from "../../src/config" -import { TuiKeybind } from "../../src/config/keybind" +import { resolve, type Info, type Resolved } from "../../src/config/v1" +import { TuiKeybind } from "../../src/config/v1/keybind" type ResolvedInput = Omit & { attention?: Partial diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 6708fd0fa4..8d0f72206f 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -1,4 +1,4 @@ -import { OpenCode, type OpenCodeEvent } from "@opencode-ai/client/promise" +import { OpenCode, type OpenCodeEvent } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" export const worktree = "/tmp/opencode" @@ -103,6 +103,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { } }) +test("formats navigation keys as arrows", async () => { + const shortcuts: Record = {} + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createResolvedKeymapConfig() + const offKeymap = registerOpencodeKeymap(keymap, renderer, config) + const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"] + const offLayer = keymap.registerLayer({ + bindings: config.keybinds.gather("test.arrows", commands), + }) + const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) + commands.forEach((command) => { + shortcuts[command] = formatKeySequence(bindings.get(command)?.[0]?.sequence, config) + }) + onCleanup(() => { + offLayer() + offKeymap() + }) + + return ( + + + + ) + } + + const app = await testRender(() => ) + try { + expect(shortcuts).toEqual({ + "session.parent": "↑", + "session.child.first": "↓", + "session.child.previous": "←", + "session.child.next": "→", + }) + } finally { + app.renderer.destroy() + } +}) + test("mode-less bindings stay active when opencode mode changes", async () => { const counts: Record> = {} diff --git a/packages/tui/test/prompt/history-provider.test.tsx b/packages/tui/test/prompt/history-provider.test.tsx new file mode 100644 index 0000000000..a49d45b932 --- /dev/null +++ b/packages/tui/test/prompt/history-provider.test.tsx @@ -0,0 +1,38 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { TuiPathsProvider } from "../../src/context/runtime" +import { PromptHistoryProvider, usePromptHistory } from "../../src/prompt/history" +import { tmpdir } from "../fixture/fixture" + +test("down rejects at the newest history item with an empty prompt", async () => { + await using tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + let history: ReturnType + + function Consumer() { + history = usePromptHistory() + return + } + + const app = await testRender(() => ( + + + + + + )) + try { + await app.renderOnce() + history!.append({ text: "previous", files: [], agents: [], pasted: [] }) + + expect(history!.move(1, "")).toBeUndefined() + expect(history!.move(-1, "")?.text).toBe("previous") + expect(history!.move(1, "previous")?.text).toBe("") + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/ui/file-path.test.ts b/packages/tui/test/ui/file-path.test.ts new file mode 100644 index 0000000000..05d2dbe3e2 --- /dev/null +++ b/packages/tui/test/ui/file-path.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { truncateFilePath } from "../../src/ui/file-path" + +describe("truncateFilePath", () => { + const path = "packages/tui/src/ui/dialog-select.tsx" + + test("keeps the full path when it fits", () => { + expect(truncateFilePath(path, 37)).toBe(path) + }) + + test("adds nearest parent segments from right to left", () => { + expect(truncateFilePath(path, 26)).toBe("…/src/ui/dialog-select.tsx") + expect(truncateFilePath(path, 22)).toBe("…/ui/dialog-select.tsx") + expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx") + }) + + test("preserves the extension when the basename must shrink", () => { + expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx") + expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx") + }) + + test("preserves the input separator", () => { + expect(truncateFilePath("packages\\tui\\src\\ui\\dialog-select.tsx", 22)).toBe("…\\ui\\dialog-select.tsx") + }) + + test("does not treat a backslash in a POSIX filename as a separator", () => { + expect(truncateFilePath("dir/file\\name.ts", 14)).toBe("…/file\\name.ts") + }) + + test("preserves absolute roots", () => { + expect(truncateFilePath("/file.ts", 7)).toBe("/fi….ts") + expect(truncateFilePath("C:\\file.ts", 9)).toBe("C:\\fi….ts") + expect(truncateFilePath("/usr/local/bin/file.ts", 14)).toBe("/…/bin/file.ts") + expect(truncateFilePath("C:\\Users\\kit\\src\\file.ts", 16)).toBe("C:\\…\\src\\file.ts") + expect(truncateFilePath("C:/Users/kit/src/file.ts", 16)).toBe("C:/…/src/file.ts") + expect(truncateFilePath("C:\\Users\\kit/src/file.ts", 16)).toBe("C:\\…\\src\\file.ts") + expect(truncateFilePath("\\\\server\\share\\src\\file.ts", 25)).toBe("\\\\server\\share\\…\\file.ts") + }) + + test("measures terminal columns without splitting graphemes", () => { + expect(truncateFilePath("packages/组件/对话框.tsx", 12)).toBe("…/对话框.tsx") + expect(truncateFilePath("src/👩‍💻-notes.tsx", 12)).toContain("👩‍💻") + expect(truncateFilePath("中a.txt", 6)).toBe("….txt") + expect(truncateFilePath("file.中a", 3)).toBe("…a") + }) + + test("never exceeds the requested width", () => { + for (let width = 0; width <= Bun.stringWidth(path); width++) { + expect(Bun.stringWidth(truncateFilePath(path, width))).toBeLessThanOrEqual(width) + } + }) +}) diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index 38ec243770..5172aa2694 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,7 +1,17 @@ import { describe, expect, test } from "bun:test" -import type { SessionMessageInfo } from "@opencode-ai/sdk/v2" +import type { SessionMessageInfo } from "@opencode-ai/client" import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session" +const assistant = (id: string, input: number): SessionMessageInfo => ({ + id, + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0 }, +}) + describe("util.session", () => { test("recognizes generated parent and child titles", () => { expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue() @@ -10,15 +20,6 @@ describe("util.session", () => { }) test("tracks usage across undo and redo boundaries", () => { - const assistant = (id: string, input: number): SessionMessageInfo => ({ - id, - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [], - tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0 }, - }) const messages = [assistant("msg_z", 10), assistant("msg_a", 30)] expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) @@ -26,4 +27,22 @@ describe("util.session", () => { expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined() expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) }) + + test("resets usage at completed compaction until the next assistant reports it", () => { + const compaction: SessionMessageInfo = { + id: "msg_compaction", + type: "compaction", + status: "completed", + reason: "manual", + summary: "Current state", + recent: "", + time: { created: 0 }, + } + const messages = [assistant("msg_before", 30), compaction] + + expect(lastAssistantWithUsage(messages)).toBeUndefined() + + messages.push(assistant("msg_after", 5)) + expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(5) + }) }) diff --git a/packages/tui/test/util/transcript.test.ts b/packages/tui/test/util/transcript.test.ts deleted file mode 100644 index 02d6ef0bb9..0000000000 --- a/packages/tui/test/util/transcript.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { formatAssistantHeader, formatMessage, formatPart, formatTranscript } from "../../src/util/transcript" -import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" - -const providers: Provider[] = [ - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - "claude-sonnet-4-20250514": { - id: "claude-sonnet-4-20250514", - providerID: "anthropic", - api: { - id: "claude-sonnet-4-20250514", - url: "https://example.com/claude-sonnet-4-20250514", - npm: "@ai-sdk/anthropic", - }, - name: "Claude Sonnet 4", - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { - text: true, - audio: false, - image: true, - video: false, - pdf: true, - }, - 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: 200_000, - output: 8_192, - }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-05-14", - }, - }, - }, -] - -describe("transcript", () => { - describe("formatAssistantHeader", () => { - const baseMsg: AssistantMessage = { - id: "msg_123", - sessionID: "ses_123", - role: "assistant", - agent: "build", - modelID: "claude-sonnet-4-20250514", - providerID: "anthropic", - mode: "", - parentID: "msg_parent", - path: { cwd: "/test", root: "/test" }, - cost: 0.001, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000000, completed: 1005400 }, - } - - test("includes metadata when enabled", () => { - const result = formatAssistantHeader(baseMsg, true) - expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n") - }) - - test("uses model display name when available", () => { - const result = formatAssistantHeader(baseMsg, true, providers) - expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n") - }) - - test("excludes metadata when disabled", () => { - const result = formatAssistantHeader(baseMsg, false) - expect(result).toBe("## Assistant\n\n") - }) - - test("handles missing completed time", () => { - const msg = { ...baseMsg, time: { created: 1000000 } } - const result = formatAssistantHeader(msg as AssistantMessage, true) - expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n") - }) - - test("titlecases agent name", () => { - const msg = { ...baseMsg, agent: "plan" } - const result = formatAssistantHeader(msg, true) - expect(result).toContain("Plan") - }) - }) - - describe("formatPart", () => { - const options = { thinking: true, toolDetails: true, assistantMetadata: true } - - test("formats text part", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "text", - text: "Hello world", - } - const result = formatPart(part, options) - expect(result).toBe("Hello world\n\n") - }) - - test("skips synthetic text parts", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "text", - text: "Synthetic content", - synthetic: true, - } - const result = formatPart(part, options) - expect(result).toBe("") - }) - - test("formats reasoning when thinking enabled", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "reasoning", - text: "Let me think...", - time: { start: 1000 }, - } - const result = formatPart(part, options) - expect(result).toBe("_Thinking:_\n\nLet me think...\n\n") - }) - - test("skips reasoning when thinking disabled", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "reasoning", - text: "Let me think...", - time: { start: 1000 }, - } - const result = formatPart(part, { ...options, thinking: false }) - expect(result).toBe("") - }) - - test("formats tool part with details", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "tool", - callID: "call_1", - tool: "bash", - state: { - status: "completed", - input: { command: "ls" }, - output: "file1.txt\nfile2.txt", - title: "List files", - metadata: {}, - time: { start: 1000, end: 1100 }, - }, - } - const result = formatPart(part, options) - expect(result).toContain("**Tool: bash**") - expect(result).toContain("**Input:**") - expect(result).toContain('"command": "ls"') - expect(result).toContain("**Output:**") - expect(result).toContain("file1.txt") - }) - - test("formats tool output containing triple backticks without breaking markdown", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "tool", - callID: "call_1", - tool: "bash", - state: { - status: "completed", - input: { command: "echo '```hello```'" }, - output: "```hello```", - title: "Echo backticks", - metadata: {}, - time: { start: 1000, end: 1100 }, - }, - } - const result = formatPart(part, options) - // The tool header should not be inside a code block - expect(result).toStartWith("**Tool: bash**\n") - // Input and output should each be in their own code blocks - expect(result).toContain("**Input:**\n```json") - expect(result).toContain("**Output:**\n```\n```hello```\n```") - }) - - test("formats tool part without details when disabled", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "tool", - callID: "call_1", - tool: "bash", - state: { - status: "completed", - input: { command: "ls" }, - output: "file1.txt", - title: "List files", - metadata: {}, - time: { start: 1000, end: 1100 }, - }, - } - const result = formatPart(part, { ...options, toolDetails: false }) - expect(result).toContain("**Tool: bash**") - expect(result).not.toContain("**Input:**") - expect(result).not.toContain("**Output:**") - }) - - test("formats tool error", () => { - const part: Part = { - id: "part_1", - sessionID: "ses_123", - messageID: "msg_123", - type: "tool", - callID: "call_1", - tool: "bash", - state: { - status: "error", - input: { command: "invalid" }, - error: "Command failed", - time: { start: 1000, end: 1100 }, - }, - } - const result = formatPart(part, options) - expect(result).toContain("**Error:**") - expect(result).toContain("Command failed") - }) - }) - - describe("formatMessage", () => { - const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers } - - test("formats user message", () => { - const msg: UserMessage = { - id: "msg_123", - sessionID: "ses_123", - role: "user", - agent: "build", - model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, - time: { created: 1000000 }, - } - const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }] - const result = formatMessage(msg, parts, options) - expect(result).toContain("## User") - expect(result).toContain("Hello") - }) - - test("formats assistant message with metadata", () => { - const msg: AssistantMessage = { - id: "msg_123", - sessionID: "ses_123", - role: "assistant", - agent: "build", - modelID: "claude-sonnet-4-20250514", - providerID: "anthropic", - mode: "", - parentID: "msg_parent", - path: { cwd: "/test", root: "/test" }, - cost: 0.001, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000000, completed: 1005400 }, - } - const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }] - const result = formatMessage(msg, parts, options) - expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)") - expect(result).toContain("Hi there") - }) - }) - - describe("formatTranscript", () => { - test("formats complete transcript", () => { - const session = { - id: "ses_abc123", - title: "Test Session", - time: { created: 1000000000000, updated: 1000000001000 }, - } - const messages = [ - { - info: { - id: "msg_1", - sessionID: "ses_abc123", - role: "user" as const, - agent: "build", - model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, - time: { created: 1000000000000 }, - }, - parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }], - }, - { - info: { - id: "msg_2", - sessionID: "ses_abc123", - role: "assistant" as const, - agent: "build", - modelID: "claude-sonnet-4-20250514", - providerID: "anthropic", - mode: "", - parentID: "msg_1", - path: { cwd: "/test", root: "/test" }, - cost: 0.001, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000000000100, completed: 1000000000600 }, - }, - parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }], - }, - ] - const options = { - thinking: false, - toolDetails: false, - assistantMetadata: true, - providers, - } - - const result = formatTranscript(session, messages, options) - - expect(result).toContain("# Test Session") - expect(result).toContain("**Session ID:** ses_abc123") - expect(result).toContain("## User") - expect(result).toContain("Hello") - expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)") - expect(result).toContain("Hi!") - expect(result).toContain("---") - }) - - test("falls back to raw model id when provider data is missing", () => { - const session = { - id: "ses_abc123", - title: "Test Session", - time: { created: 1000000000000, updated: 1000000001000 }, - } - const messages = [ - { - info: { - id: "msg_1", - sessionID: "ses_abc123", - role: "assistant" as const, - agent: "build", - modelID: "claude-sonnet-4-20250514", - providerID: "anthropic", - mode: "", - parentID: "msg_0", - path: { cwd: "/test", root: "/test" }, - cost: 0.001, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000000000100, completed: 1000000000600 }, - }, - parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }], - }, - ] - - const result = formatTranscript(session, messages, { - thinking: false, - toolDetails: false, - assistantMetadata: true, - }) - - expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)") - }) - - test("formats transcript without assistant metadata", () => { - const session = { - id: "ses_abc123", - title: "Test Session", - time: { created: 1000000000000, updated: 1000000001000 }, - } - const messages = [ - { - info: { - id: "msg_1", - sessionID: "ses_abc123", - role: "assistant" as const, - agent: "build", - modelID: "claude-sonnet-4-20250514", - providerID: "anthropic", - mode: "", - parentID: "msg_0", - path: { cwd: "/test", root: "/test" }, - cost: 0.001, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000000000100, completed: 1000000000600 }, - }, - parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }], - }, - ] - const options = { thinking: false, toolDetails: false, assistantMetadata: false } - - const result = formatTranscript(session, messages, options) - - expect(result).toContain("## Assistant\n\n") - expect(result).not.toContain("Build") - expect(result).not.toContain("claude-sonnet-4-20250514") - }) - }) -}) diff --git a/specs/v2/README.md b/specs/v2/README.md index b46edec8a8..49fe529fa6 100644 --- a/specs/v2/README.md +++ b/specs/v2/README.md @@ -29,6 +29,7 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ | Document | Status | Job | | ----------------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------- | +| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. | | [Managed restart continuation](./session-restart-continuation.md) | Accepted and implemented | Record why graceful managed-service restart uses private Session suspension. | | [Instruction sync](./instruction-sync-proposal.md) | Accepted and implemented | Record why instruction state is value deltas plus derived rendering. | | [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. | diff --git a/specs/v2/event-stream-architecture.md b/specs/v2/event-stream-architecture.md new file mode 100644 index 0000000000..34eb8f137c --- /dev/null +++ b/specs/v2/event-stream-architecture.md @@ -0,0 +1,227 @@ +# V2 Event Stream Architecture + +## Decision + +The public HTTP event stream uses one Server-scoped encoded feed with one independently bounded queue per connection. + +```text +Core EventV2.listen() + | + | one global subscription + v +Server EventFeed + public filter + schema encode + JSON encode + SSE frame once + | + | nonblocking offer of one shared immutable string + v + Queue A Queue B Queue C + | | | + HTTP A HTTP B HTTP C +``` + +Core owns event meaning, publication, persistence, typed observation, durable logs, replay, and transactional projection. + +Server owns public event selection, wire encoding, bounded connection delivery, and subscriber lifecycle. + +Protocol continues to own the `OpenCodeEvent` SSE contract. Generated Promise and Effect clients remain unchanged. + +## Context + +Before this change, every `/api/event` connection called `EventV2.liveBounded`. Each call registered a Core callback listener and allocated a dropping queue of raw event payloads. Every HTTP connection then independently performed: + +1. Public-event filtering. +2. `OpenCodeEvent` schema encoding. +3. `JSON.stringify`. +4. SSE framing. +5. UTF-8 encoding. + +With `N` connected TUIs, schema and wire encoding therefore ran `N` times for every accepted event. + +`liveBounded` was introduced before zero-argument `events.subscribe()` became the unified live interface. It stayed on deprecated `listen` because it provided a stronger contract than the shared unbounded Core PubSub: one slow subscriber could overflow and fail without blocking healthy subscribers. + +The global cross-location event stream is intentional. The endpoint is outside `LocationMiddleware`, and the TUI uses event location metadata to update state for multiple locations. The feed must not add request-location filtering. + +## Delivery Law + +The Server feed preserves this law: + +> A connection has an independent finite lag budget. Exceeding it terminates only that connection while publication and healthy connections continue in order. + +Each connection receives a `Queue.dropping` with capacity 4,096 accepted public frames. + +When an offer returns `false`: + +1. The queue is removed from the active subscriber registry immediately. +2. The queue is failed with `SubscriberOverflowError`. +3. The same frame is still offered to every other active queue. +4. Core publication and the Server observer never suspend on that connection. + +Previously accepted frames drain before the queue failure surfaces. The overflow-causing frame is not accepted by that connection. + +Internal Core events, `server.connected`, and heartbeats do not consume the queue capacity. + +## Why Independent Queues + +The design was reviewed twice, including explicit consideration of one shared Effect PubSub of encoded frames. + +### Shared PubSub benefits + +A shared PubSub stores each frame once and gives each subscriber a cursor. Its retained feed storage is proportional to maximum lag rather than the sum of every subscriber's lag. + +### Shared PubSub costs + +Effect's bounded PubSub strategies do not directly express independent subscriber failure: + +- `bounded` can suspend the shared publisher behind the slowest subscriber; +- `dropping` rejects one publication for every subscriber when shared capacity is full; +- `sliding` silently skips events while leaving the stale subscriber connected; +- `unbounded` removes the structural memory bound. + +Independent eviction can be built on a dropping PubSub, but requires: + +- retaining every subscription's child scope; +- a separate typed overflow signal because subscription closure appears as interruption/completion; +- serialization of registration, removal, eviction, and publication; +- lag scans at capacity; +- waiting for scope closure to release shared ring slots; +- terminal handling if a supposedly impossible shared publish returns `false`; +- immediate discard of the stale subscriber's previously accepted unread backlog. + +That is a custom multicast protocol layered over PubSub. + +The incremental benefit is queue-slot references, not encoded frame copies: every independent queue stores the same immutable encoded string reference. At 50 clients each retaining 4,096 frames, raw references are roughly 1.6 MiB before array overhead. HTTP runtime, TLS, kernel, proxy, and client buffers may dominate that cost. + +The chosen queue design captures the dominant optimization, encode once, while retaining direct queue-local overflow semantics and a smaller failure domain. + +Revisit shared PubSub storage only if measurements after shared encoding show queue reference retention or per-queue offers are material. + +## Capacity + +The migration preserves the existing 4,096-event capacity. + +This is compatibility, not a claim that 4,096 is optimal. It is an event-count lag threshold, not a complete memory bound: + +- frames vary in size; +- stream pulls may move batches into HTTP buffers before queue lag reflects them; +- kernel and client buffers are outside Server accounting. + +Do not raise capacity merely because frames are encoded once. A larger threshold retains stale clients longer. + +Tune capacity separately using observed: + +- public event rates and burst sizes; +- healthy subscriber queue high-water marks; +- encoded frame-size distribution; +- overflow and reconnect frequency; +- retained heap and RSS; +- downstream drain duration under a stalled reader. + +Add a byte budget only if measurements show event count is an inadequate memory safeguard. + +## Feed Lifecycle + +### Server scope + +`EventFeed.layer` is built once with the Server handler graph. It registers one global Core listener outside request location middleware. + +The listener is installed synchronously before the feed service is exposed. Public filtering, encoding, and nonblocking queue offers happen inline once per Core event. This avoids both a startup gap and an unbounded asynchronous ingress backlog. + +Core invokes the one observer sequentially. It does not fork encoding or fan-out per event, so every healthy subscriber observes the same order. + +When no HTTP subscribers are registered, the observer returns before wire encoding, so headless and idle servers do not pay serialization cost. + +### Connection scope + +Each `feed.subscribe` acquisition: + +1. Allocates one dropping queue. +2. Registers it synchronously. +3. Returns `Stream.fromQueue(queue)`. +4. Removes and shuts down the queue when the request scope closes. + +The raw handler acquires and registers the queue before prepending its connection-specific `server.connected` frame: + +```text +register queue + -> emit server.connected + -> drain queued live frames +``` + +Events before registration may be missed, consistent with a volatile stream. Events after registration queue behind `server.connected`. + +Heartbeats remain connection-local and outside the feed. + +### Encoding failure + +If one accepted public event cannot be encoded: + +1. Log its ID, type, and cause. +2. Fail every currently connected queue with `EncodingError`. +3. Skip the malformed volatile event. +4. Keep the feed available for later connections and valid events. + +Keeping current clients connected would create a silent gap. Permanently terminating the feed would poison future connections. + +## HTTP And Code Generation + +Protocol remains unchanged: + +```ts +HttpApiSchema.StreamSse({ data: OpenCodeEvent }) +``` + +The raw handler continues to own: + +- the unique `server.connected` event; +- the 15-second heartbeat; +- SSE response headers; +- `HttpServerResponse.stream` construction. + +The feed supplies complete immutable SSE frame strings for ordinary public events. The handler merges connection-local frames and performs text-to-byte encoding. + +Because method, path, schema, and wire representation do not change: + +- OpenAPI does not change; +- generated Promise clients do not change; +- generated Effect clients do not change; +- TUI decoding and reconnect behavior do not change; +- client regeneration is not required. + +## Core Cleanup + +The Server no longer uses `EventV2.liveBounded`, so Core removes that dead helper and its transport-specific overflow error. The feed registers one observer through the existing `listen` interface; other listeners are unchanged. + +Transactional projector registration is unrelated and remains unchanged. + +## Benchmark + +The disposable benchmark reproduced the previous per-connection schema/JSON/SSE encoding path with a representative 8 KiB public event. It used one warmup and nine measured runs; median was the primary metric and median absolute deviation was reported. The benchmark was intentionally not committed because it isolated the removed encoding boundary rather than exercising the complete HTTP stack. + +Results on Apple Silicon with Bun 1.3.14: + +| Clients | Current median | Shared median | Change | +| ------: | -------------: | ------------: | -----: | +| 1 | 9.488 ms | 9.554 ms | +0.7% | +| 10 | 96.312 ms | 10.352 ms | -89.3% | +| 50 | 553.928 ms | 12.389 ms | -97.8% | + +The benchmark isolates the repeated encoding boundary. It does not claim to measure socket throughput, client decoding, or downstream HTTP buffering. Queue offers and socket writes remain proportional to connected clients. + +An experiment replacing direct schema encoding plus `JSON.stringify` with `Schema.fromJsonString(OpenCodeEvent)` was discarded: the one-client median regressed from approximately 9.5 ms to 38.8 ms with substantially higher variance. + +## Verification + +Behavioral tests cover: + +- one encoding operation for multiple subscribers; +- identical frame delivery to healthy subscribers; +- independent slow-subscriber overflow; +- healthy delivery of events after another subscriber overflows; +- filtering internal events before capacity; +- failure of current subscribers after malformed public encoding; +- continued delivery to later subscribers after an encoding failure. + +Package typechecks and the existing Core event/event-logger suites protect the Core interface migration.