diff --git a/.gitignore b/.gitignore index 006cab8c27..ba80d79b90 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ tmp dist ts-dist .turbo +.typecheck-profiles **/.serena .serena/ **/.omo diff --git a/.opencode/skills/debug-opencode/SKILL.md b/.opencode/skills/debug-opencode/SKILL.md deleted file mode 100644 index 01e0ca3523..0000000000 --- a/.opencode/skills/debug-opencode/SKILL.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -name: debug-opencode -description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector. ---- - -# Debugging opencode itself - -Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise. - -## Migration context - -- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. -- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing. - -## Server/client model - -opencode V2 is a client/server system, not a single monolithic process: - -- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`). -- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`). -- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible. -- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself. -- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one. -- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions. -- Every log line is tagged `role=server` or `role=cli` and a per-process `run=`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes. - -## Starting the dev TUI - -- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. -- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server. - -## Interactive debugging with termctrl - -- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. -- Use a dedicated session name and do not reuse or kill an unrelated session. - -```bash -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev -termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 -termctrl show opencode-v2-dev -``` - -- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. -- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. - -```bash -termctrl send opencode-v2-dev 'text:example prompt' enter -termctrl send opencode-v2-dev ctrl-c -``` - -- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. -- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. - -```bash -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png -``` - -- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: - -```bash -termctrl resize opencode-v2-dev --cols 100 --rows 30 -termctrl show opencode-v2-dev -``` - -- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. -- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`. -- Always clean up the Terminal Control session when the check is complete: - -```bash -termctrl stop opencode-v2-dev -``` - -## Comparing V2 against the legacy TUI - -Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: - -```bash -# From packages/cli: local V2 TUI -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev - -# Released legacy TUI behavior reference -termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest - -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png -termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png -``` - -- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. -- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. - -## Server/API debugging - -- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI. -- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering. -- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path: - -```bash -bun dev api get /health -bun dev api get /openapi.json -bun dev api --param key=value -``` - -- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`. -- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. -- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. - -## Auditing installed `opencode2` sessions - -Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy. - -For a supplied `ses_...` ID, compare three sources: - -- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state. -- The database's ordered `event` rows for durable history. -- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering. - -Locate an uncertain database without modifying it: - -```bash -SESSION=ses_... -for db in ~/.local/share/opencode/*.db; do - sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db" -done -``` - -## Logs - -- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine. -- Each line is structured `key=value` text: `timestamp`, `level`, `run=` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file. -- Tail the live file while reproducing an issue instead of guessing from stale output: - -```bash -tail -f ~/.local/share/opencode/log/opencode-local.log -``` - -- Filter to one run or role when the file is noisy: - -```bash -grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log -grep 'role=server' ~/.local/share/opencode/log/opencode-local.log -``` - -- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro. -- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file. -- `termctrl logs ` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead. - -## Debugger - -- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: - -```bash -termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ - bun run --inspect=ws://localhost:6499/ src/index.ts -``` - -- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. -- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. - -## Verification - -- Run `bun typecheck` from `packages/cli` after CLI adapter changes. -- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. -- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. diff --git a/AGENTS.md b/AGENTS.md index c89653ba95..d6bd72d4b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. +Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user. + ## Style Guide ### General Principles diff --git a/bun.lock b/bun.lock index fd8ba59e27..df33000ba5 100644 --- a/bun.lock +++ b/bun.lock @@ -442,6 +442,12 @@ "@parcel/watcher-win32-x64": "2.5.1", }, }, + "packages/docs": { + "name": "@opencode-ai/docs", + "devDependencies": { + "mint": "4.2.666", + }, + }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", "version": "1.17.14", @@ -520,10 +526,10 @@ "name": "@opencode-ai/http-recorder", "version": "1.17.14", "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", }, "devDependencies": { + "@effect/platform-node": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", @@ -532,7 +538,7 @@ "typescript": "catalog:", }, "peerDependencies": { - "effect": "4.0.0-beta.83", + "effect": "catalog:", }, }, "packages/httpapi-codegen": { @@ -867,6 +873,8 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { + "@fontsource/adwaita-mono": "5.2.1", + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", @@ -1259,6 +1267,8 @@ "@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -1267,6 +1277,10 @@ "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@ark/schema": ["@ark/schema@0.55.0", "", { "dependencies": { "@ark/util": "0.55.0" } }, "sha512-IlSIc0FmLKTDGr4I/FzNHauMn0MADA6bCjT1wauu4k6MyxhC1R9gz0olNpIRvK7lGGDwtc/VO0RUDNvVQW5WFg=="], + + "@ark/util": ["@ark/util@0.55.0", "", {}, "sha512-aWFNK7aqSvqFtVsl1xmbTjGbg91uqtJV7Za76YGNEwIO4qLjMfyY8flmmbhooYMuqPCO2jyxu8hve943D+w3bA=="], + "@ast-grep/cli": ["@ast-grep/cli@0.44.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.44.0", "@ast-grep/cli-darwin-x64": "0.44.0", "@ast-grep/cli-linux-arm64-gnu": "0.44.0", "@ast-grep/cli-linux-x64-gnu": "0.44.0", "@ast-grep/cli-win32-arm64-msvc": "0.44.0", "@ast-grep/cli-win32-ia32-msvc": "0.44.0", "@ast-grep/cli-win32-x64-msvc": "0.44.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-Jf4PuP7XjzsMa3m9gYxmzV8KyWZc4w1ZzKe/t0+90wWxmSasQJe6AtMkJxHEi98MGgfAF1nWziqjDd0/6EsBjA=="], "@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bF7euu/hF/cYg4510z8110vh60rrqfrBdsfRqVGd6xqNSPENu7CJnTVN/Z4Nk5U1NM8YKzUD+dYx1ySUJ0CUNQ=="], @@ -1311,6 +1325,10 @@ "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.4", "", { "dependencies": { "yaml": "^2.8.3" } }, "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A=="], + "@asyncapi/parser": ["@asyncapi/parser@3.4.0", "", { "dependencies": { "@asyncapi/specs": "^6.8.0", "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", "@stoplight/json": "3.21.0", "@stoplight/json-ref-readers": "^1.2.2", "@stoplight/json-ref-resolver": "^3.1.5", "@stoplight/spectral-core": "^1.18.3", "@stoplight/spectral-functions": "^1.7.2", "@stoplight/spectral-parsers": "^1.0.2", "@stoplight/spectral-ref-resolver": "^1.0.3", "@stoplight/types": "^13.12.0", "@types/json-schema": "^7.0.11", "@types/urijs": "^1.19.19", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "avsc": "^5.7.5", "js-yaml": "^4.1.0", "jsonpath-plus": "^10.0.0", "node-fetch": "2.6.7" } }, "sha512-Sxn74oHiZSU6+cVeZy62iPZMFMvKp4jupMFHelSICCMw1qELmUHPvuZSr+ZHDmNGgHcEpzJM5HN02kR7T4g+PQ=="], + + "@asyncapi/specs": ["@asyncapi/specs@6.8.1", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], @@ -1505,6 +1523,8 @@ "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="], + "@canvas/image-data": ["@canvas/image-data@1.1.0", "", {}, "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA=="], + "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], @@ -1593,11 +1613,11 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@emotion/is-prop-valid": ["@emotion/is-prop-valid@0.8.8", "", { "dependencies": { "@emotion/memoize": "0.7.4" } }, "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA=="], @@ -1705,6 +1725,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource/adwaita-mono": ["@fontsource/adwaita-mono@5.2.1", "", {}, "sha512-6+Q1UIvklJ9REijs6kv7YlRNt6yktRj0iW8H69YIugdD9P2h3eIX1AB8/9ICMfpVyVeywlsrCXg82y/LfRrjyg=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], @@ -1771,6 +1793,38 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], + + "@inquirer/editor": ["@inquirer/editor@4.2.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/external-editor": "^1.0.3", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ=="], + + "@inquirer/expand": ["@inquirer/expand@4.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/input": ["@inquirer/input@4.3.1", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g=="], + + "@inquirer/number": ["@inquirer/number@3.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg=="], + + "@inquirer/password": ["@inquirer/password@4.0.23", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA=="], + + "@inquirer/prompts": ["@inquirer/prompts@7.9.0", "", { "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", "@inquirer/editor": "^4.2.21", "@inquirer/expand": "^4.0.21", "@inquirer/input": "^4.2.5", "@inquirer/number": "^3.0.21", "@inquirer/password": "^4.0.21", "@inquirer/rawlist": "^4.1.9", "@inquirer/search": "^3.2.0", "@inquirer/select": "^4.4.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@4.1.11", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw=="], + + "@inquirer/search": ["@inquirer/search@3.2.2", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA=="], + + "@inquirer/select": ["@inquirer/select@4.4.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w=="], + + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], @@ -1805,6 +1859,12 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="], + + "@jsep-plugin/regex": ["@jsep-plugin/regex@1.0.4", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg=="], + + "@jsep-plugin/ternary": ["@jsep-plugin/ternary@1.1.4", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg=="], + "@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="], "@jsx-email/body": ["@jsx-email/body@1.0.2", "", { "peerDependencies": { "react": "^18.2.0" } }, "sha512-NjR2tgLH4XGfGkm+O8kcVwi9MBqZsXZCLlmk3HlMux3/n/+a5zB+yhJqXWZBJl2i+6cSF+E2O6hK11ekyK9WWQ=="], @@ -1879,6 +1939,26 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mintlify/cli": ["@mintlify/cli@4.0.1269", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.985", "@mintlify/link-rot": "3.0.1172", "@mintlify/models": "0.0.333", "@mintlify/prebuild": "1.0.1131", "@mintlify/previewing": "4.0.1197", "@mintlify/validation": "0.1.769", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.1", "mdast-util-mdx-jsx": "3.2.0", "open": "8.4.2", "openid-client": "6.8.2", "posthog-node": "5.17.2", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "4.3.6" }, "optionalDependencies": { "keytar": "7.9.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-l9b7InT55JWXV7TU7Jr4Wrijv4/gMFHLQyWJ7fcjqpSxAetR+xNyeEARRlcf7OicGTjZuvmRGGfj75kp3O4p7A=="], + + "@mintlify/common": ["@mintlify/common@1.0.985", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@asyncapi/specs": "6.8.1", "@mintlify/mdx": "3.0.4", "@mintlify/models": "0.0.333", "@mintlify/openapi-parser": "0.0.8", "@mintlify/validation": "0.1.769", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.1", "lodash": "4.18.1", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.14", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "sucrase": "3.34.0", "tailwindcss": "3.4.17", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3", "xss": "1.0.15" } }, "sha512-eJPeR99AKgVifXLdiA2hhfNy2+CmZ3zqQAscRXmFbJFST8SgsUj6rU3D2fx0XYt38b7T3LqEMD7DH5ipSC+Zfg=="], + + "@mintlify/link-rot": ["@mintlify/link-rot@3.0.1172", "", { "dependencies": { "@mintlify/common": "1.0.985", "@mintlify/models": "0.0.333", "@mintlify/prebuild": "1.0.1131", "@mintlify/previewing": "4.0.1197", "@mintlify/scraping": "4.0.849", "@mintlify/validation": "0.1.769", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-8962sk/WO/0YcSkHTiaVZ2DvrCb8TC4BPgp21a9qW6nsPKKNixMhsC2uUB90D+gOzPTejJl0NNd4gLk4fA3SKw=="], + + "@mintlify/mdx": ["@mintlify/mdx@3.0.4", "", { "dependencies": { "@shikijs/transformers": "^3.11.0", "@shikijs/twoslash": "^3.12.2", "arktype": "^2.1.26", "hast-util-to-string": "^3.0.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-hast": "^13.2.0", "next-mdx-remote-client": "^1.0.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-smartypants": "^3.0.2", "shiki": "^3.11.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@radix-ui/react-popover": "^1.1.15", "react": "^18.3.1", "react-dom": "^18.3.1" } }, "sha512-tJhdpnM5ReJLNJ2fuDRIEr0zgVd6id7/oAIfs26V46QlygiLsc8qx4Rz3LWIX51rUXW/cfakjj0EATxIciIw+g=="], + + "@mintlify/models": ["@mintlify/models@0.0.333", "", { "dependencies": { "axios": "1.16.1", "openapi-types": "12.1.3" } }, "sha512-0uAsuTsV8gYCDpv4aA0MWilQu+a/mrK+G6q5FVcgunbEZ3meeCXfrQFTviaEw7A+0cR/7Pc2KLA66cPDm+3Qdg=="], + + "@mintlify/openapi-parser": ["@mintlify/openapi-parser@0.0.8", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-9MBRq9lS4l4HITYCrqCL7T61MOb20q9IdU7HWhqYMNMM1jGO1nHjXasFy61yZ8V6gMZyyKQARGVoZ0ZrYN48Og=="], + + "@mintlify/prebuild": ["@mintlify/prebuild@1.0.1131", "", { "dependencies": { "@mintlify/common": "1.0.985", "@mintlify/openapi-parser": "0.0.8", "@mintlify/scraping": "4.0.849", "@mintlify/validation": "0.1.769", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.1", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.1" } }, "sha512-EbPf1/z1m8K/Jl4qXggiMQwfdqXLF25sj+d5SHaGl6T0auTOYMayN578Rb/qM4fjhhlBQwub919Zsq9syYeYUA=="], + + "@mintlify/previewing": ["@mintlify/previewing@4.0.1197", "", { "dependencies": { "@mintlify/common": "1.0.985", "@mintlify/prebuild": "1.0.1131", "@mintlify/validation": "0.1.769", "adm-zip": "0.5.16", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.22.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.1", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.8.0", "tar": "7.5.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-q4TunK8KjE1k9ve5nOAQTvBpsE7bX+RJN/ozx7RdIyQSCSexpkywmSM6nE3ECJyjUWFy8pg6yrYYbUQLHN/oww=="], + + "@mintlify/scraping": ["@mintlify/scraping@4.0.849", "", { "dependencies": { "@mintlify/common": "1.0.985", "@mintlify/openapi-parser": "0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.1", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-4aMltLtfSU5rkUJt2SCVaYIbuggiEnx7lMWKM8NW93SaZUeoL0iKNbo0K24SmOqS7kYATRRH8zI7gefPX2ZLDw=="], + + "@mintlify/validation": ["@mintlify/validation@0.1.769", "", { "dependencies": { "@mintlify/mdx": "3.0.4", "@mintlify/models": "0.0.333", "arktype": "2.1.27", "fractional-indexing": "3.2.0", "js-yaml": "4.1.1", "lcm": "0.0.3", "lodash": "4.18.1", "neotraverse": "0.6.18", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.1", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-8Sg6DCdQ7RSc3NIyaSSWy7G6KaAuw0NfUSBCYLwGhtp6dYPh1jEsPn6YU8hqUMNZn1OQHsA52rA6lZQxjnyLHQ=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -1907,6 +1987,30 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.2", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.2", "@napi-rs/canvas-darwin-arm64": "1.0.2", "@napi-rs/canvas-darwin-x64": "1.0.2", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", "@napi-rs/canvas-linux-arm64-musl": "1.0.2", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-musl": "1.0.2", "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", "@napi-rs/canvas-win32-x64-msvc": "1.0.2" } }, "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -1993,6 +2097,8 @@ "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], + "@openapi-contrib/openapi-schema-to-json-schema": ["@openapi-contrib/openapi-schema-to-json-schema@3.2.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-Gj6C0JwCr8arj0sYuslWXUBSP/KnUlEGnPW4qxlXvAl543oaNQgMgIgkQUA6vs5BCCvwTEiL8m/wdWzfl4UvSw=="], + "@openauthjs/openauth": ["@openauthjs/openauth@0.0.0-20250322224806", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-p5IWSRXvABcwocH2dNI0w8c1QJelIOFulwhKk+aLLFfUbs8u1pr7kQbYe8yCSM2+bcLHiwbogpUQc2ovrGwCuw=="], "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], @@ -2019,6 +2125,8 @@ "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], + "@opencode-ai/docs": ["@opencode-ai/docs@workspace:packages/docs"], + "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], "@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"], @@ -2197,7 +2305,7 @@ "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], - "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], @@ -2391,6 +2499,8 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@posthog/core": ["@posthog/core@1.7.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA=="], + "@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="], "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], @@ -2421,6 +2531,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="], + "@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="], @@ -2485,7 +2597,37 @@ "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], @@ -2539,6 +2681,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="], @@ -2595,6 +2739,8 @@ "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], + "@shikijs/twoslash": ["@shikijs/twoslash@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0", "twoslash": "^0.3.6" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-pNaLJWMA3LU7PhT8tm9OQBZ1epy0jmdgeJzntBtr1EVXLbHxGzTj3mnf9vOdcl84l96qnlJXkJ/NGXZYBpXl5g=="], + "@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -2615,6 +2761,10 @@ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/slugify": ["@sindresorhus/slugify@2.2.0", "", { "dependencies": { "@sindresorhus/transliterate": "^1.0.0", "escape-string-regexp": "^5.0.0" } }, "sha512-9Vybc/qX8Kj6pxJaapjkFbiUJPk7MAkCh/GFCxIBnnsuYCFPIXKvnLidG8xlepht3i24L5XemUmGtrJ3UWrl6w=="], + + "@sindresorhus/transliterate": ["@sindresorhus/transliterate@1.6.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="], + "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="], @@ -2769,6 +2919,36 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@stoplight/better-ajv-errors": ["@stoplight/better-ajv-errors@1.0.3", "", { "dependencies": { "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA=="], + + "@stoplight/json": ["@stoplight/json@3.21.0", "", { "dependencies": { "@stoplight/ordered-object-literal": "^1.0.3", "@stoplight/path": "^1.3.2", "@stoplight/types": "^13.6.0", "jsonc-parser": "~2.2.1", "lodash": "^4.17.21", "safe-stable-stringify": "^1.1" } }, "sha512-5O0apqJ/t4sIevXCO3SBN9AHCEKKR/Zb4gaj7wYe5863jme9g02Q0n/GhM7ZCALkL+vGPTe4ZzTETP8TFtsw3g=="], + + "@stoplight/json-ref-readers": ["@stoplight/json-ref-readers@1.2.2", "", { "dependencies": { "node-fetch": "^2.6.0", "tslib": "^1.14.1" } }, "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ=="], + + "@stoplight/json-ref-resolver": ["@stoplight/json-ref-resolver@3.1.6", "", { "dependencies": { "@stoplight/json": "^3.21.0", "@stoplight/path": "^1.3.2", "@stoplight/types": "^12.3.0 || ^13.0.0", "@types/urijs": "^1.19.19", "dependency-graph": "~0.11.0", "fast-memoize": "^2.5.2", "immer": "^9.0.6", "lodash": "^4.17.21", "tslib": "^2.6.0", "urijs": "^1.19.11" } }, "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A=="], + + "@stoplight/ordered-object-literal": ["@stoplight/ordered-object-literal@1.0.5", "", {}, "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg=="], + + "@stoplight/path": ["@stoplight/path@1.3.2", "", {}, "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ=="], + + "@stoplight/spectral-core": ["@stoplight/spectral-core@1.23.1", "", { "dependencies": { "@scarf/scarf": "^1.4.0", "@stoplight/better-ajv-errors": "1.0.3", "@stoplight/json": "~3.21.0", "@stoplight/path": "1.3.2", "@stoplight/spectral-parsers": "^1.0.0", "@stoplight/spectral-ref-resolver": "^1.0.4", "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "~13.6.0", "@types/es-aggregate-error": "^1.0.2", "@types/json-schema": "^7.0.11", "ajv": "^8.18.0", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.1", "es-aggregate-error": "^1.0.7", "expr-eval-fork": "^3.0.1", "jsonpath-plus": "^10.3.0", "lodash": "^4.18.1", "lodash.topath": "^4.5.2", "minimatch": "^3.1.4", "nimma": "0.2.3", "pony-cause": "^1.1.1", "tslib": "^2.8.1" } }, "sha512-VLC8OhpO/pMJKb6IHhurxJjXO1qB56Ng1unIb8b+hNxdw0+SEcASvmR+RpjfHYX/jv/DfSaA1x8QhFBJBmqBOQ=="], + + "@stoplight/spectral-formats": ["@stoplight/spectral-formats@1.8.5", "", { "dependencies": { "@scarf/scarf": "^1.4.0", "@stoplight/json": "^3.17.0", "@stoplight/spectral-core": "^1.23.0", "@types/json-schema": "^7.0.7", "tslib": "^2.8.1" } }, "sha512-xaC0rCH0p7/bzNJsz+JgLSj+Cp6uwYGWpePQxdLkF2G6a8Zyp3OyS7umkGYNiimEwKrOjvCNNTFJpeuiENZSBA=="], + + "@stoplight/spectral-functions": ["@stoplight/spectral-functions@1.10.5", "", { "dependencies": { "@scarf/scarf": "^1.4.0", "@stoplight/better-ajv-errors": "1.0.3", "@stoplight/json": "^3.17.1", "@stoplight/spectral-core": "^1.23.0", "@stoplight/spectral-formats": "^1.8.1", "@stoplight/spectral-runtime": "^1.1.2", "ajv": "^8.18.0", "ajv-draft-04": "~1.0.0", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.1", "lodash": "^4.18.1", "tslib": "^2.8.1" } }, "sha512-vDCd0NJ93715bcUpZZ5vNHiyxd4cgHF6tuXsDiXOXKAByg+I1fR5/dMijEo6Ce1Lz95a+RZ22JKYhF1YuzVvuA=="], + + "@stoplight/spectral-parsers": ["@stoplight/spectral-parsers@1.0.5", "", { "dependencies": { "@stoplight/json": "~3.21.0", "@stoplight/types": "^14.1.1", "@stoplight/yaml": "~4.3.0", "tslib": "^2.8.1" } }, "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ=="], + + "@stoplight/spectral-ref-resolver": ["@stoplight/spectral-ref-resolver@1.0.5", "", { "dependencies": { "@stoplight/json-ref-readers": "1.2.2", "@stoplight/json-ref-resolver": "~3.1.6", "@stoplight/spectral-runtime": "^1.1.2", "dependency-graph": "0.11.0", "tslib": "^2.8.1" } }, "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA=="], + + "@stoplight/spectral-runtime": ["@stoplight/spectral-runtime@1.1.6", "", { "dependencies": { "@stoplight/json": "^3.20.1", "@stoplight/path": "^1.3.2", "@stoplight/types": "^13.6.0", "lodash": "^4.18.1", "node-fetch": "^2.7.0", "tslib": "^2.8.1" } }, "sha512-Y8rEDyMN4bSMJCrDs2shdcVHYyCnH3FvXRP4dBhha4Z8iJv+JPp7KqOV/hwVB/hWFC209upiwj2oDmLfR0qCDg=="], + + "@stoplight/types": ["@stoplight/types@13.20.0", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA=="], + + "@stoplight/yaml": ["@stoplight/yaml@4.3.0", "", { "dependencies": { "@stoplight/ordered-object-literal": "^1.0.5", "@stoplight/types": "^14.1.1", "@stoplight/yaml-ast-parser": "0.0.50", "tslib": "^2.2.0" } }, "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w=="], + + "@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="], + "@storybook/addon-a11y": ["@storybook/addon-a11y@10.4.1", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.4.1" } }, "sha512-MGft/IXjJ20a9KbaSVG9bHTAAoanbucKrgEiJJRNqpim8DsXA01+XTdSk17LmiOCB203Rrq9mWgdQ6+79cc8iA=="], "@storybook/addon-docs": ["@storybook/addon-docs@10.4.1", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.4.1", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.4.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.4.1" }, "optionalPeers": ["@types/react"] }, "sha512-IYqUdjoZe4VO2LFZlKL/gwy7DsQSWCq6hX+zc1MBmZo04yycDASk1tte57n9pdlW3ajw9yYMF/+lVBi+xQjyvw=="], @@ -2847,6 +3027,8 @@ "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="], "@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="], @@ -2869,6 +3051,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -2893,6 +3077,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="], "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], @@ -2905,6 +3091,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/es-aggregate-error": ["@types/es-aggregate-error@1.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -2981,6 +3169,8 @@ "@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -3011,6 +3201,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/urijs": ["@types/urijs@1.19.26", "", {}, "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg=="], + "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], @@ -3051,6 +3243,12 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], + "@vercel/cli-config": ["@vercel/cli-config@0.2.0", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.0", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug=="], + + "@vercel/functions": ["@vercel/functions@3.7.5", "", { "dependencies": { "@vercel/oidc": "3.8.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-ESf8BbeDebqRUyMi09JwRbQqpLn4g6fjcVVHPsHB56j2dSqRrSHO4h3X4aaxJf6iQQjzhAtDGI2xCWQ27JE8PA=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -3109,10 +3307,16 @@ "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + "address": ["address@1.2.2", "", {}, "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA=="], + + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="], + "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], @@ -3121,6 +3325,8 @@ "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-errors": ["ajv-errors@3.0.0", "", { "peerDependencies": { "ajv": "^8.0.1" } }, "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], @@ -3129,6 +3335,8 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3157,6 +3365,10 @@ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "arkregex": ["arkregex@0.0.3", "", { "dependencies": { "@ark/util": "0.55.0" } }, "sha512-bU21QJOJEFJK+BPNgv+5bVXkvRxyAvgnon75D92newgHxkBJTgiFwQxusyViYyJkETsddPlHyspshDQcCzmkNg=="], + + "arktype": ["arktype@2.1.27", "", { "dependencies": { "@ark/schema": "0.55.0", "@ark/util": "0.55.0", "arkregex": "0.0.3" } }, "sha512-enctOHxI4SULBv/TDtCVi5M8oLd4J5SVlPUblXDzSsOYQNMzmVbUosGBnJuZDKmFlN5Ie0/QVEuTE+Z5X1UhsQ=="], + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], @@ -3201,10 +3413,14 @@ "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "avsc": ["avsc@5.7.9", "", {}, "sha512-yOA4wFeI7ET3v32Di/sUybQ+ttP20JHSW3mxLuNGeO0uD6PPcvLrIQXSvy/rhJOWU5JrYh7U4OHplWMmtAtjMg=="], + "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], @@ -3249,14 +3465,20 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], + "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + "bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="], "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], @@ -3265,6 +3487,8 @@ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], @@ -3335,6 +3559,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], @@ -3359,6 +3585,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], @@ -3371,6 +3599,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chromium-bidi": ["chromium-bidi@0.6.2", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-4WVBa6ijmUTVr9cZD4eicQD8Mdy/HCX3bzEIYYpmk0glqYLoWH+LqQEvV9RpDRzoQSbY1KJHloYXbDMXMbDPhg=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], @@ -3381,12 +3611,18 @@ "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], + "clean-stack": ["clean-stack@4.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], @@ -3403,10 +3639,14 @@ "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color-blend": ["color-blend@4.0.0", "", {}, "sha512-fYODTHhI/NG+B5GnzvuL3kiFrK/UnkUezWFTgEPBTY5V+kpyfAn95Vn9sJeeCX6omrCOdxnqCL3CvH+6sXtIbw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -3445,6 +3685,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="], @@ -3455,6 +3697,8 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -3481,6 +3725,8 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cssfilter": ["cssfilter@0.0.10", "", {}, "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -3515,12 +3761,18 @@ "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], + "decode-bmp": ["decode-bmp@0.2.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "to-data-view": "^1.1.0" } }, "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA=="], + + "decode-ico": ["decode-ico@0.4.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "decode-bmp": "^0.2.0", "to-data-view": "^1.1.0" } }, "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -3537,12 +3789,16 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dependency-graph": ["dependency-graph@0.11.0", "", {}, "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg=="], + "deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -3557,12 +3813,16 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "detect-port": ["detect-port@1.5.1", "", { "dependencies": { "address": "^1.0.1", "debug": "4" }, "bin": { "detect": "bin/detect-port.js", "detect-port": "bin/detect-port.js" } }, "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ=="], + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "devtools-protocol": ["devtools-protocol@0.0.1312386", "", {}, "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], "diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="], @@ -3585,6 +3845,8 @@ "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "dns-socket": ["dns-socket@4.2.2", "", { "dependencies": { "dns-packet": "^5.2.4" } }, "sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -3667,6 +3929,8 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="], + "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], @@ -3677,14 +3941,20 @@ "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + "es-aggregate-error": ["es-aggregate-error@1.0.14", "", { "dependencies": { "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "globalthis": "^1.0.4", "has-property-descriptors": "^1.0.2", "set-function-name": "^2.0.2" } }, "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA=="], + "es-array-method-boxes-properly": ["es-array-method-boxes-properly@1.0.0", "", {}, "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -3701,6 +3971,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -3719,8 +3991,12 @@ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], @@ -3735,6 +4011,8 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -3753,10 +4031,14 @@ "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "expr-eval-fork": ["expr-eval-fork@3.0.3", "", {}, "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -3791,6 +4073,8 @@ "fast-json-stringify": ["fast-json-stringify@6.4.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ=="], + "fast-memoize": ["fast-memoize@2.5.2", "", {}, "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw=="], + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], @@ -3805,6 +4089,10 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + + "favicons": ["favicons@7.2.0", "", { "dependencies": { "escape-html": "^1.0.3", "sharp": "^0.33.1", "xml2js": "^0.6.1" } }, "sha512-k/2rVBRIRzOeom3wI9jBPaSEvoTSQEW4iM0EveBmBBKFxO8mSyyRWtDlfC3VnEfu0avmjrMzy8/ZFPSe6F71Hw=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -3843,6 +4131,8 @@ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -3851,10 +4141,16 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="], + "framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], @@ -3873,6 +4169,8 @@ "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "gcd": ["gcd@0.0.1", "", {}, "sha512-VNx3UEGr+ILJTiMs1+xc5SX1cMgJCrXezKPa003APUWNqQqaF6n25W8VcR7nHN6yRWbvvUTwCpZCFJeWC2kXlw=="], + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], @@ -3899,10 +4197,14 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="], @@ -3959,8 +4261,12 @@ "hast-util-format": ["hast-util-format@1.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-minify-whitespace": "^1.0.0", "hast-util-phrasing": "^3.0.0", "hast-util-whitespace": "^3.0.0", "html-whitespace-sensitive-tag-names": "^3.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA=="], + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], "hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="], @@ -3987,6 +4293,8 @@ "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + "hast-util-to-mdast": ["hast-util-to-mdast@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-phrasing": "^3.0.0", "hast-util-to-html": "^9.0.0", "hast-util-to-text": "^4.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "mdast-util-to-string": "^4.0.0", "rehype-minify-whitespace": "^6.0.0", "trim-trailing-lines": "^2.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-DsL/SvCK9V7+vfc6SLQ+vKIyBDXTk2KLSbfBYkH4zeF/uR1yBajHRhkzuaUSGOB1WJSTieJBdHwxlC+HLKvZZw=="], + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], @@ -4001,6 +4309,8 @@ "heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="], + "hex-rgb": ["hex-rgb@5.0.0", "", {}, "sha512-NQO+lgVUCtHxZ792FodgW0zflK+ozS9X9dwGp9XvvmPlH7pyxd588cn24TD3rmPm/N0AIRXF10Otah8yKqGw4w=="], + "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], @@ -4045,6 +4355,8 @@ "i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="], + "ico-endec": ["ico-endec@0.1.6", "", {}, "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ=="], + "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -4057,6 +4369,8 @@ "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -4069,8 +4383,14 @@ "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "ink": ["ink@6.3.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.32.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-2CbJAa7XeziZYe6pDS5RVLirRY28iSGMQuEV8jRU5NQsONQNfcR/BZHHc9vkMg2lGYTHTM2pskxC1YmY28p6bQ=="], + + "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "inquirer": ["inquirer@12.3.0", "", { "dependencies": { "@inquirer/core": "^10.1.2", "@inquirer/prompts": "^7.2.1", "@inquirer/type": "^3.0.2", "ansi-escapes": "^4.3.2", "mute-stream": "^2.0.0", "run-async": "^3.0.0", "rxjs": "^7.8.1" }, "peerDependencies": { "@types/node": ">=18" } }, "sha512-3NixUXq+hM8ezj2wc7wC37b32/rHq1MwNZDYdvx+d6jokOD+r+i8Q4Pkylh9tISYP114A128LCX8RKhopC5RfQ=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -4079,6 +4399,8 @@ "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + "ip-regex": ["ip-regex@4.3.0", "", {}, "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -4131,10 +4453,14 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-ip": ["is-ip@3.1.0", "", { "dependencies": { "ip-regex": "^4.0.0" } }, "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], @@ -4143,6 +4469,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-online": ["is-online@10.0.0", "", { "dependencies": { "got": "^12.1.0", "p-any": "^4.0.0", "p-timeout": "^5.1.0", "public-ip": "^5.0.0" } }, "sha512-WCPdKwNDjXJJmUubf2VHLMDBkUZEtuOvpXUfUnUFbEnM6In9ByiScL4f4jKACz/fsb2qDkesFerW3snf/AYz3A=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -4215,6 +4543,8 @@ "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], @@ -4247,6 +4577,10 @@ "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + "jsonpath-plus": ["jsonpath-plus@10.4.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA=="], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], @@ -4261,6 +4595,8 @@ "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + "keytar": ["keytar@7.9.0", "", { "dependencies": { "node-addon-api": "^4.3.0", "prebuild-install": "^7.0.1" } }, "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -4279,8 +4615,12 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "lcm": ["lcm@0.0.3", "", { "dependencies": { "gcd": "^0.0.1" } }, "sha512-TB+ZjoillV6B26Vspf9l2L/vKaRY/4ep3hahcyVkCGFgsTNRUQdc24bQeNFiZeoxH0vr5+7SfNRMQuPHv/1IrQ=="], + "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], @@ -4331,6 +4671,8 @@ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "lodash.topath": ["lodash.topath@4.5.2", "", {}, "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg=="], + "loglevelnext": ["loglevelnext@6.0.0", "", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -4387,6 +4729,8 @@ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], @@ -4399,6 +4743,8 @@ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -4435,6 +4781,8 @@ "micromark-extension-directive": ["micromark-extension-directive@3.0.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA=="], + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], @@ -4449,9 +4797,11 @@ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], - "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.1", "", { "dependencies": { "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg=="], "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], @@ -4537,8 +4887,14 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mint": ["mint@4.2.666", "", { "dependencies": { "@mintlify/cli": "4.0.1269" }, "bin": { "mint": "index.js" } }, "sha512-FsdL35EH++MiVDoKxN8M6/obOsrgx0Ko7P/1Y2lBXh/jEPx1UD1Y8msmAOW6cuwaqGIzAxuC7ySmr7D3G2bmBg=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], @@ -4563,6 +4919,8 @@ "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -4573,12 +4931,20 @@ "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + + "next-mdx-remote-client": ["next-mdx-remote-client@1.1.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@types/mdx": "^2.0.13", "remark-mdx-remove-esm": "^1.3.2", "serialize-error": "^13.0.1", "vfile": "^6.0.3", "vfile-matter": "^5.0.1" }, "peerDependencies": { "react": ">= 18.3.0 < 19.0.0", "react-dom": ">= 18.3.0 < 19.0.0" } }, "sha512-IElOrn02JjGQZxx+re7wMx/1AUG+Arte9aDImAtxjAfMw6xuSCaH5mTCunKelkWzFyFdRb565jO8jRICvvh96g=="], + "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], + "nimma": ["nimma@0.2.3", "", { "dependencies": { "@jsep-plugin/regex": "^1.0.1", "@jsep-plugin/ternary": "^1.0.2", "astring": "^1.8.1", "jsep": "^1.2.0" }, "optionalDependencies": { "jsonpath-plus": "^6.0.1 || ^10.1.0", "lodash.topath": "^4.5.2" } }, "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA=="], + "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -4611,6 +4977,8 @@ "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "non-error": ["non-error@0.1.0", "", {}, "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -4637,6 +5005,8 @@ "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], + "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], @@ -4683,6 +5053,8 @@ "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], + "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], @@ -4697,6 +5069,8 @@ "oxlint-tsgolint": ["oxlint-tsgolint@0.21.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.21.0", "@oxlint-tsgolint/darwin-x64": "0.21.0", "@oxlint-tsgolint/linux-arm64": "0.21.0", "@oxlint-tsgolint/linux-x64": "0.21.0", "@oxlint-tsgolint/win32-arm64": "0.21.0", "@oxlint-tsgolint/win32-x64": "0.21.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-HiWPhANwRnN1pZJQ2SgNB3WRR+1etLJHmRzQ/MJhyINsEIaOUCjxhlXJKbEaVUwdnyXwRWqo/P9Fx21lz0/mSg=="], + "p-any": ["p-any@4.0.0", "", { "dependencies": { "p-cancelable": "^3.0.0", "p-some": "^6.0.0" } }, "sha512-S/B50s+pAVe0wmEZHmBs/9yJXeZ5KhHzOsgKzt0hRdgkoR3DxW9ts46fcsWi/r3VnzsnkKS7q4uimze+zjdryw=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="], @@ -4713,10 +5087,16 @@ "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "p-some": ["p-some@6.0.0", "", { "dependencies": { "aggregate-error": "^4.0.0", "p-cancelable": "^3.0.0" } }, "sha512-CJbQCKdfSX3fIh8/QKgS+9rjm7OBNUTmwWswAFQAhc8j1NR1dsEDETUEuVUtQHZpV+J03LqWBEwvu0g1Yn+TYg=="], + "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -4729,10 +5109,14 @@ "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -4747,6 +5131,8 @@ "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -4811,6 +5197,8 @@ "poe-oauth": ["poe-oauth@0.0.8", "", {}, "sha512-zlaRVLR6vuxBIYUkZoTIVo3f8h3qd27gv9Ms+kmGiYEiiV4TdccddTdNcGyI0DnuJ9tVi+5LP3Bvzez59IFbjw=="], + "pony-cause": ["pony-cause@1.1.1", "", {}, "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -4831,6 +5219,8 @@ "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + "posthog-node": ["posthog-node@5.17.2", "", { "dependencies": { "@posthog/core": "1.7.1" } }, "sha512-lz3YJOr0Nmiz0yHASaINEDHqoV+0bC3eD8aZAG+Ky292dAnVYul+ga/dMX8KCBXg8hHfKdxw0SztYD5j6dgUqQ=="], + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], @@ -4839,6 +5229,8 @@ "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="], @@ -4879,14 +5271,22 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + "public-ip": ["public-ip@5.0.0", "", { "dependencies": { "dns-socket": "^4.2.2", "got": "^12.0.0", "is-ip": "^3.1.0" } }, "sha512-xaH3pZMni/R2BG7ZXXaWS9Wc9wFlhyDVJF47IJ+3ali0TGv+2PsckKxbmo+rnx3ZxiV2wblVhtdS3bohAP6GGw=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + "puppeteer": ["puppeteer@22.14.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1312386", "puppeteer-core": "22.14.0" }, "bin": { "puppeteer": "lib/esm/puppeteer/node/cli.js" } }, "sha512-MGTR6/pM8zmWbTdazb6FKnwIihzsSEXBPH49mFFU96DNZpQOevCAZMnjBZGlZRGRzRK6aADCavR6SQtrbv5dQw=="], + + "puppeteer-core": ["puppeteer-core@22.14.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "chromium-bidi": "0.6.2", "debug": "^4.3.5", "devtools-protocol": "0.0.1312386", "ws": "^8.18.0" } }, "sha512-rl4tOY5LcA3e374GAlsGGHc05HL3eGNf5rZ+uxkl6id9zVZKcwcp1Z+Nd6byb6WPiPeecT/dwz8f/iUm+AZQSw=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -4909,6 +5309,8 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="], @@ -4919,6 +5321,8 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.5.5", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw=="], @@ -4979,6 +5383,10 @@ "rehype-format": ["rehype-format@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-format": "^1.0.0" } }, "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ=="], + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "rehype-minify-whitespace": ["rehype-minify-whitespace@6.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-minify-whitespace": "^1.0.0" } }, "sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw=="], + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], @@ -4989,11 +5397,19 @@ "relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="], + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], + "remark-directive": ["remark-directive@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^3.0.0", "unified": "^11.0.0" } }, "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A=="], + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-mdx": ["remark-mdx@3.1.0", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA=="], + + "remark-mdx-remove-esm": ["remark-mdx-remove-esm@1.3.2", "", { "dependencies": { "@types/mdast": "^4.0.4", "unist-util-remove": "^4.0.0" }, "peerDependencies": { "unified": "^11" } }, "sha512-BvL8VSdVXy9S7NlHP56nUJAHFc45h5E9HnHiLUGHe5tw3Yvm/3cVZvAzlkEEh2i+fkq2uKrf2xn5VmItBhMypA=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -5029,6 +5445,8 @@ "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], @@ -5051,6 +5469,8 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -5059,8 +5479,12 @@ "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "run-async": ["run-async@3.0.0", "", {}, "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], @@ -5117,6 +5541,8 @@ "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + "sharp-ico": ["sharp-ico@0.1.5", "", { "dependencies": { "decode-ico": "*", "ico-endec": "*", "sharp": "*" } }, "sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -5139,6 +5565,10 @@ "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -5155,6 +5585,10 @@ "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "socket.io": ["socket.io@4.8.0", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.3.2", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA=="], + + "socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], @@ -5225,6 +5659,8 @@ "sst-win32-x86": ["sst-win32-x86@4.13.1", "", { "os": "win32", "cpu": "none" }, "sha512-YPxBVdac/MsrzwlC6pF0NrrvMcmfdBLYjv7MbzHc5jNh1FQ1WPh6bdWQqgv0KD9EQTNLLEkej0beydgUvcCWJg=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], @@ -5273,6 +5709,8 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="], "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], @@ -5307,6 +5745,8 @@ "tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], + "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], @@ -5327,6 +5767,8 @@ "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], @@ -5353,6 +5795,8 @@ "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "to-data-view": ["to-data-view@1.1.0", "", {}, "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="], @@ -5377,6 +5821,8 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trim-trailing-lines": ["trim-trailing-lines@2.1.0", "", {}, "sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -5397,6 +5843,8 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], @@ -5405,6 +5853,10 @@ "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], + "twoslash": ["twoslash@0.3.9", "", { "dependencies": { "@typescript/vfs": "^1.6.4", "twoslash-protocol": "0.3.9" }, "peerDependencies": { "typescript": "^5.5.0 || ^6.0.0" } }, "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q=="], + + "twoslash-protocol": ["twoslash-protocol@0.3.9", "", {}, "sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], @@ -5433,6 +5885,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], @@ -5449,16 +5903,22 @@ "unifont": ["unifont@0.5.2", "", { "dependencies": { "css-tree": "^3.0.0", "ofetch": "^1.4.1", "ohash": "^2.0.0" } }, "sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg=="], + "unist-builder": ["unist-builder@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg=="], + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + "unist-util-map": ["unist-util-map@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-HJs1tpkSmRJUzj6fskQrS5oYhBYlmtcvy4SepdDEEsL04FjBrgF0Mgggvxc1/qGBGgW7hRh9+UBK1aqTEnBpIA=="], + "unist-util-modify-children": ["unist-util-modify-children@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" } }, "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + "unist-util-remove": ["unist-util-remove@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg=="], + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], @@ -5491,6 +5951,10 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="], + + "urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -5501,6 +5965,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], @@ -5519,6 +5985,8 @@ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + "vfile-matter": ["vfile-matter@5.0.1", "", { "dependencies": { "vfile": "^6.0.0", "yaml": "^2.0.0" } }, "sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw=="], + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], @@ -5623,8 +6091,12 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -5633,6 +6105,8 @@ "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "xss": ["xss@1.0.15", "", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -5655,6 +6129,10 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -5759,6 +6237,10 @@ "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@alcalzone/ansi-tokenize/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "@astrojs/cloudflare/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -5781,6 +6263,12 @@ "@astrojs/starlight/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@asyncapi/parser/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "@asyncapi/parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], @@ -5971,6 +6459,8 @@ "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jsx-email/cli/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], @@ -5985,8 +6475,120 @@ "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@mdx-js/mdx/remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@mintlify/cli/chalk": ["chalk@5.2.0", "", {}, "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA=="], + + "@mintlify/cli/fs-extra": ["fs-extra@11.2.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw=="], + + "@mintlify/cli/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@mintlify/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@mintlify/cli/openid-client": ["openid-client@6.8.2", "", { "dependencies": { "jose": "^6.1.3", "oauth4webapi": "^3.8.4" } }, "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA=="], + + "@mintlify/cli/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "@mintlify/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@mintlify/cli/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "@mintlify/cli/yargs": ["yargs@17.7.1", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw=="], + + "@mintlify/cli/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@mintlify/common/acorn": ["acorn@8.11.2", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w=="], + + "@mintlify/common/hast-util-to-html": ["hast-util-to-html@9.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA=="], + + "@mintlify/common/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@mintlify/common/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "@mintlify/common/mdast-util-gfm": ["mdast-util-gfm@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw=="], + + "@mintlify/common/mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], + + "@mintlify/common/postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + + "@mintlify/common/remark-gfm": ["remark-gfm@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA=="], + + "@mintlify/common/remark-rehype": ["remark-rehype@11.1.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ=="], + + "@mintlify/common/sucrase": ["sucrase@3.34.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "7.1.6", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw=="], + + "@mintlify/common/tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], + + "@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "@mintlify/common/unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + + "@mintlify/link-rot/fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="], + + "@mintlify/link-rot/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="], + + "@mintlify/mdx/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], + + "@mintlify/mdx/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "@mintlify/mdx/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "@mintlify/prebuild/chalk": ["chalk@5.3.0", "", {}, "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="], + + "@mintlify/prebuild/fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="], + + "@mintlify/prebuild/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@mintlify/prebuild/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="], + + "@mintlify/prebuild/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + + "@mintlify/previewing/chalk": ["chalk@5.2.0", "", {}, "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA=="], + + "@mintlify/previewing/chokidar": ["chokidar@3.5.3", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw=="], + + "@mintlify/previewing/express": ["express@4.22.0", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ=="], + + "@mintlify/previewing/fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="], + + "@mintlify/previewing/got": ["got@13.0.0", "", { "dependencies": { "@sindresorhus/is": "^5.2.0", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^10.2.8", "decompress-response": "^6.0.0", "form-data-encoder": "^2.1.2", "get-stream": "^6.0.1", "http2-wrapper": "^2.1.10", "lowercase-keys": "^3.0.0", "p-cancelable": "^3.0.0", "responselike": "^3.0.0" } }, "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA=="], + + "@mintlify/previewing/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@mintlify/previewing/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "@mintlify/previewing/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="], + + "@mintlify/previewing/yargs": ["yargs@17.7.1", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw=="], + + "@mintlify/scraping/fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], + + "@mintlify/scraping/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@mintlify/scraping/mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], + + "@mintlify/scraping/remark-gfm": ["remark-gfm@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA=="], + + "@mintlify/scraping/remark-mdx": ["remark-mdx@3.0.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-3Pz3yPQ5Rht2pM5R+0J2MrGoBSrzf+tJG94N+t/ilfdh8YLyyKYtidAYwTveB20BoHAcwIopOUqhcmh2F7hGYA=="], + + "@mintlify/scraping/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "@mintlify/scraping/yargs": ["yargs@17.7.1", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw=="], + + "@mintlify/scraping/zod": ["zod@3.24.0", "", {}, "sha512-Hz+wiY8yD0VLA2k/+nsg2Abez674dDGTai33SwNvMPuf9uIrBC9eFgIMQxBBbHFxVXi8W+5nX9DcAh9YNSQm/w=="], + + "@mintlify/validation/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@mintlify/validation/object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "@mintlify/validation/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + + "@mintlify/validation/zod": ["zod@3.24.0", "", {}, "sha512-Hz+wiY8yD0VLA2k/+nsg2Abez674dDGTai33SwNvMPuf9uIrBC9eFgIMQxBBbHFxVXi8W+5nX9DcAh9YNSQm/w=="], + + "@mintlify/validation/zod-to-json-schema": ["zod-to-json-schema@3.20.4", "", { "peerDependencies": { "zod": "^3.20.0" } }, "sha512-Un9+kInJ2Zt63n6Z7mLqBifzzPcOyX+b+Exuzf7L1+xqck9Q2EPByyTRduV3kmSPaXaRer1JCsucubpgL1fipg=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -6091,6 +6693,10 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -6105,6 +6711,14 @@ "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + "@puppeteer/browsers/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@puppeteer/browsers/tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], + + "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], @@ -6129,6 +6743,10 @@ "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@shikijs/twoslash/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/twoslash/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -6161,6 +6779,28 @@ "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "@stoplight/json/jsonc-parser": ["jsonc-parser@2.2.1", "", {}, "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w=="], + + "@stoplight/json/safe-stable-stringify": ["safe-stable-stringify@1.1.1", "", {}, "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw=="], + + "@stoplight/json-ref-readers/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "@stoplight/json-ref-resolver/immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="], + + "@stoplight/spectral-core/@stoplight/types": ["@stoplight/types@13.6.0", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ=="], + + "@stoplight/spectral-core/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "@stoplight/spectral-core/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@stoplight/spectral-functions/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "@stoplight/spectral-parsers/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], + + "@stoplight/yaml/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -6187,6 +6827,14 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.0", "", { "dependencies": { "@vercel/cli-config": "0.2.0", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A=="], + + "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -6199,6 +6847,8 @@ "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + "aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], @@ -6255,6 +6905,12 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -6263,6 +6919,8 @@ "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -6273,10 +6931,16 @@ "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "cosmiconfig/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -6319,8 +6983,14 @@ "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -6335,6 +7005,8 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "favicons/xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -6345,6 +7017,8 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="], "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -6363,6 +7037,26 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "ink/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "ink/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "ink/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "ink/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "ink/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "ink-spinner/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "inquirer/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "is-online/got": ["got@12.6.1", "", { "dependencies": { "@sindresorhus/is": "^5.2.0", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^10.2.8", "decompress-response": "^6.0.0", "form-data-encoder": "^2.1.2", "get-stream": "^6.0.1", "http2-wrapper": "^2.1.10", "lowercase-keys": "^3.0.0", "p-cancelable": "^3.0.0", "responselike": "^3.0.0" } }, "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ=="], + + "is-online/p-timeout": ["p-timeout@5.1.0", "", {}, "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew=="], + "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -6371,6 +7065,8 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], @@ -6381,6 +7077,8 @@ "micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "micromark-extension-mdxjs/micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -6397,6 +7095,10 @@ "motion/framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], + "next-mdx-remote-client/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "next-mdx-remote-client/serialize-error": ["serialize-error@13.0.1", "", { "dependencies": { "non-error": "^0.1.0", "type-fest": "^5.4.1" } }, "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA=="], + "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], "nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], @@ -6429,12 +7131,20 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "p-any/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "p-some/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-json/json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -6455,6 +7165,8 @@ "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "prebuild-install/node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -6463,8 +7175,24 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "public-ip/got": ["got@12.6.1", "", { "dependencies": { "@sindresorhus/is": "^5.2.0", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^10.2.8", "decompress-response": "^6.0.0", "form-data-encoder": "^2.1.2", "get-stream": "^6.0.1", "http2-wrapper": "^2.1.10", "lowercase-keys": "^3.0.0", "p-cancelable": "^3.0.0", "responselike": "^3.0.0" } }, "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ=="], + + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "react-reconciler/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], @@ -6481,12 +7209,18 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], "sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -6499,6 +7233,10 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -6517,6 +7255,8 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "unbzip2-stream/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6561,6 +7301,8 @@ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "xss/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], @@ -6645,6 +7387,8 @@ "@astrojs/starlight/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -6727,6 +7471,10 @@ "@hey-api/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -6789,6 +7537,160 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "@mintlify/cli/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "@mintlify/cli/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "@mintlify/cli/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "@mintlify/cli/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "@mintlify/cli/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "@mintlify/cli/openid-client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "@mintlify/cli/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@mintlify/cli/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@mintlify/common/hast-util-to-html/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + + "@mintlify/common/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "@mintlify/common/mdast-util-gfm/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "@mintlify/common/mdast-util-mdx-jsx/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "@mintlify/common/remark-gfm/mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "@mintlify/common/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "@mintlify/common/sucrase/glob": ["glob@7.1.6", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA=="], + + "@mintlify/common/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "@mintlify/common/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "@mintlify/common/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "@mintlify/common/tailwindcss/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "@mintlify/common/tailwindcss/object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "@mintlify/common/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "@mintlify/common/tailwindcss/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "@mintlify/common/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "@mintlify/link-rot/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "@mintlify/link-rot/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "@mintlify/link-rot/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="], + + "@mintlify/link-rot/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="], + + "@mintlify/mdx/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "@mintlify/mdx/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + + "@mintlify/mdx/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@mintlify/mdx/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@mintlify/mdx/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@mintlify/mdx/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@mintlify/mdx/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@mintlify/mdx/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@mintlify/prebuild/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "@mintlify/prebuild/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "@mintlify/prebuild/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "@mintlify/prebuild/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="], + + "@mintlify/prebuild/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="], + + "@mintlify/previewing/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "@mintlify/previewing/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "@mintlify/previewing/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + + "@mintlify/previewing/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "@mintlify/previewing/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "@mintlify/previewing/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "@mintlify/previewing/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "@mintlify/previewing/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + + "@mintlify/previewing/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "@mintlify/previewing/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "@mintlify/previewing/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + + "@mintlify/previewing/express/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + + "@mintlify/previewing/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "@mintlify/previewing/express/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "@mintlify/previewing/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "@mintlify/previewing/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "@mintlify/previewing/got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], + + "@mintlify/previewing/got/@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], + + "@mintlify/previewing/got/cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="], + + "@mintlify/previewing/got/cacheable-request": ["cacheable-request@10.2.14", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.2", "get-stream": "^6.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.3", "mimic-response": "^4.0.0", "normalize-url": "^8.0.0", "responselike": "^3.0.0" } }, "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ=="], + + "@mintlify/previewing/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], + + "@mintlify/previewing/got/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@mintlify/previewing/got/http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], + + "@mintlify/previewing/got/lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], + + "@mintlify/previewing/got/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + + "@mintlify/previewing/got/responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="], + + "@mintlify/previewing/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "@mintlify/previewing/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "@mintlify/previewing/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="], + + "@mintlify/previewing/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="], + + "@mintlify/previewing/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@mintlify/previewing/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@mintlify/scraping/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "@mintlify/scraping/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "@mintlify/scraping/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@mintlify/scraping/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@mintlify/validation/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], @@ -6895,10 +7797,20 @@ "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@puppeteer/browsers/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@puppeteer/browsers/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], @@ -6961,12 +7873,30 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -7035,12 +7965,20 @@ "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "better-opn/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "better-opn/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "conf/dot-prop/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "cosmiconfig/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], @@ -7091,6 +8029,10 @@ "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "engine.io/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "engine.io/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7101,6 +8043,30 @@ "iconv-corefoundation/cli-truncate/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "ink/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "inquirer/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "is-online/got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], + + "is-online/got/@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], + + "is-online/got/cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="], + + "is-online/got/cacheable-request": ["cacheable-request@10.2.14", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.2", "get-stream": "^6.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.3", "mimic-response": "^4.0.0", "normalize-url": "^8.0.0", "responselike": "^3.0.0" } }, "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ=="], + + "is-online/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], + + "is-online/got/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "is-online/got/http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], + + "is-online/got/lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], + + "is-online/got/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + + "is-online/got/responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="], + "js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -7119,6 +8085,8 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "next-mdx-remote-client/serialize-error/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="], @@ -7133,14 +8101,44 @@ "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "prebuild-install/node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "public-ip/got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], + + "public-ip/got/@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], + + "public-ip/got/cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="], + + "public-ip/got/cacheable-request": ["cacheable-request@10.2.14", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.2", "get-stream": "^6.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.3", "mimic-response": "^4.0.0", "normalize-url": "^8.0.0", "responselike": "^3.0.0" } }, "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ=="], + + "public-ip/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], + + "public-ip/got/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "public-ip/got/http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], + + "public-ip/got/lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], + + "public-ip/got/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], + + "public-ip/got/responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "socket.io/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "socket.io/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "tw-to-css/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -7295,6 +8293,10 @@ "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@jsx-email/cli/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -7343,6 +8345,64 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@mintlify/cli/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mintlify/cli/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@mintlify/cli/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@mintlify/cli/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mintlify/common/remark-gfm/mdast-util-gfm/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "@mintlify/common/sucrase/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@mintlify/common/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "@mintlify/common/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "@mintlify/common/tailwindcss/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "@mintlify/previewing/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "@mintlify/previewing/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "@mintlify/previewing/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "@mintlify/previewing/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "@mintlify/previewing/express/body-parser/qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "@mintlify/previewing/express/body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "@mintlify/previewing/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "@mintlify/previewing/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "@mintlify/previewing/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "@mintlify/previewing/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "@mintlify/previewing/got/cacheable-request/mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], + + "@mintlify/previewing/got/cacheable-request/normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], + + "@mintlify/previewing/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mintlify/previewing/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@mintlify/previewing/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@mintlify/previewing/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mintlify/scraping/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mintlify/scraping/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@mintlify/scraping/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@mintlify/scraping/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7369,6 +8429,14 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@puppeteer/browsers/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@puppeteer/browsers/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@puppeteer/browsers/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -7393,6 +8461,10 @@ "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "@stoplight/spectral-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -7451,6 +8523,8 @@ "electron-builder/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "engine.io/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "esbuild-plugin-copy/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7459,6 +8533,10 @@ "iconv-corefoundation/cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "is-online/got/cacheable-request/mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], + + "is-online/got/cacheable-request/normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], + "js-beautify/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "js-beautify/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7473,10 +8551,16 @@ "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "public-ip/got/cacheable-request/mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], + + "public-ip/got/cacheable-request/normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], + "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "socket.io/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -7517,6 +8601,30 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@mintlify/cli/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@mintlify/cli/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@mintlify/common/sucrase/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "@mintlify/common/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "@mintlify/previewing/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "@mintlify/previewing/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "@mintlify/previewing/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@mintlify/previewing/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@mintlify/scraping/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@mintlify/scraping/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@puppeteer/browsers/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@puppeteer/browsers/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -7557,6 +8665,8 @@ "tw-to-css/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 8ffb18491e..e427ceb127 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "lint": "oxlint", "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src", "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", - "typecheck": "bun turbo typecheck", + "typecheck": "bun turbo typecheck --concurrency=3", + "typecheck:profile": "bun script/profile-typecheck.ts", + "typecheck:profile:packages": "bun script/profile-typecheck-packages.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", "prepare": "husky", diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts index e0f0d72233..0690f02706 100644 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -20,7 +20,7 @@ const profiles = [ { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, { name: "multi patch", - tool: "apply_patch", + tool: "patch", input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, }, ] as const diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts index 798bf0df3b..f411339adb 100644 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( userMessage(), assistantMessage( [ - toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), + toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), textPart(followingID, "Following incremental patch"), ], { completed: false }, @@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( partUpdated( toolPart( patchID, - "apply_patch", + "patch", "running", { files: [first.filePath, second.filePath] }, { metadata: { files: [first, second] } }, @@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( partUpdated( toolPart( patchID, - "apply_patch", + "patch", "completed", { files: [first.filePath, second.filePath, third.filePath] }, { metadata: { files: [first, second, third] } }, diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index a86a55cff2..a22d5cc331 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -295,7 +295,7 @@ function performanceTurn(index: number) { messageID: assistantID, type: "tool", callID: `call_0000_${suffix}_patch`, - tool: "apply_patch", + tool: "patch", state: { status: "completed", input: { patchText: realisticPatch(index) }, diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index 529081a1d9..e6fdd5d47b 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -131,7 +131,7 @@ function toolPart( ): MessagePart { const metadata = metadataOverride ?? - (tool === "apply_patch" + (tool === "patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -219,7 +219,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] : []), ...(index % 8 === 0 - ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index f07da121c6..a591ff9470 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => { assistantMessage([ toolPart( id, - "apply_patch", + "patch", "completed", { files: ["src/a.ts"] }, { diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index cb228c13c7..f0871a0da3 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn assistantMessage([ toolPart( patchID, - "apply_patch", + "patch", "completed", { files: files.map((file) => file.filePath) }, { metadata: { files } }, diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 9fd2ca8d0b..b6679ab4ed 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -246,7 +246,7 @@ function editPart(id: string) { function patchPart(id: string) { return toolPart( id, - "apply_patch", + "patch", "completed", { files: ["src/a.ts", "src/b.ts"] }, { diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 99f1acf270..071b030078 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -8,7 +8,7 @@ import { } from "../performance/timeline-stability/fixture" test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { - const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] + const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] const parts = ordinary.map((tool, index) => toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), ) @@ -90,7 +90,7 @@ function questionInput() { function errorInput(tool: string) { if (tool === "bash") return { command: "exit 1" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } - if (tool === "apply_patch") return { files: ["src/error.ts"] } + if (tool === "patch") return { files: ["src/error.ts"] } if (tool === "webfetch") return { url: "https://example.com" } if (tool === "websearch") return { query: "failure" } if (tool === "task") return { description: "Fail task", subagent_type: "explore" } diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 3dce37cafd..ff857d8179 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -120,7 +120,7 @@ function toolPart( outputLength = 160, ): MessagePart { const metadata = - tool === "apply_patch" + tool === "patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -199,7 +199,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] : []), ...(index % 8 === 0 - ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e5df40c7d2..53adfd155b 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -55,7 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 15d125df39..40b7acbbd3 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,7 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" @@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: { break } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505ea..3dda5a5429 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -5,7 +5,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" @@ -33,7 +33,7 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record @@ -67,7 +67,7 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc84643..39535abe2f 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -4,7 +4,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" @@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd09..a117e4d007 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -13,7 +13,7 @@ import type { ReferenceInfo, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" @@ -51,7 +51,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } todo: { [sessionID: string]: Todo[] diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 7b592178fa..052faf496a 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -126,8 +126,9 @@ describe("enqueueServerEvent", () => { enqueue(partUpdated("old")) enqueue({ + id: "event-delete", type: "session.deleted", - properties: { sessionID: "session", info: { id: "session" } }, + properties: { sessionID: "session" }, } as Event) enqueue(partUpdated("new")) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 6898cc2304..a46eb744d1 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -8,7 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import { batch } from "solid-js" @@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, @@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) return } diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3854bf0276..586942399d 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,6 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3f44aba488..01384a6ee9 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -23,7 +23,6 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { @@ -36,15 +35,9 @@ import { import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff - -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { - return typeof value.file === "string" -} - export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => (FileDiffInfo | VcsFileDiff)[] diffsReady: () => boolean empty: () => string hasReview: () => boolean @@ -59,7 +52,6 @@ export function SessionSidePanel(props: { }) { const layout = useLayout() const settings = useSettings() - const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() @@ -88,7 +80,7 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffs = createMemo(() => props.diffs()) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2e2c65af3b..bb7950079e 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1245,7 +1245,7 @@ export function MessageTimeline(props: { const value = row() if (value._tag !== "AssistantPart" || value.group.type !== "part") return false const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) - return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) + return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool) } const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) let contentMeasureFrame: number | undefined diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index c288c35706..760ace3311 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,17 +1,13 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { - return typeof value.file === "string" -} - export function reviewDiffKinds(diffs: RenderDiff[]) { const merge = (a: Kind | undefined, b: Kind) => { if (!a) return b diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index fcd52756ab..41f75a081e 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal, Show, type JSX } from "solid-js" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -21,16 +21,11 @@ import type { import FileTreeV2 from "@/components/file-tree-v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" -import { - filterRenderableDiff, - filterReviewFiles, - reviewDiffKinds, - type RenderDiff, -} from "@/pages/session/v2/review-diff-kinds" +import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds" import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element @@ -54,7 +49,7 @@ export type ReviewPanelV2Props = { export function ReviewPanelV2(props: ReviewPanelV2Props) { const sdk = useSDK() - const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) + const diffs = createMemo(() => props.diffs()) const filteredFiles = createMemo(() => filterReviewFiles( diffs().map((diff) => diff.file), diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index 5fbca469b7..f6d768e1de 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -9,7 +9,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe..60df039410 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,7 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 209f03d623..73bebeb94f 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -3,96 +3,5 @@ ## Migration context - The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. -- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI. -- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: - -```bash -# From packages/cli: local V2 TUI -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev - -# Released legacy TUI behavior reference -termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest - -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png -termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png -``` - -- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. -- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. - -## Interactive debugging - -- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. -- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server. -- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. -- Use a dedicated session name and do not reuse or kill an unrelated session. - -```bash -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev -termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 -termctrl show opencode-v2-dev -``` - -- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. -- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. - -```bash -termctrl send opencode-v2-dev 'text:example prompt' enter -termctrl send opencode-v2-dev ctrl-c -``` - -- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. -- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. - -```bash -termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png -``` - -- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: - -```bash -termctrl resize opencode-v2-dev --cols 100 --rows 30 -termctrl show opencode-v2-dev -``` - -- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. -- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`. -- Always clean up the Terminal Control session when the check is complete: - -```bash -termctrl stop opencode-v2-dev -``` - -## Server/API debugging - -- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI. -- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering. -- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path: - -```bash -bun dev api get /health -bun dev api get /openapi.json -bun dev api --param key=value -``` - -- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`. -- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. -- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. - -## Debugger - -- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: - -```bash -termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ - bun run --inspect=ws://localhost:6499/ src/index.ts -``` - -- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. -- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. - -## Verification - -- Run `bun typecheck` from `packages/cli` after CLI adapter changes. -- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. -- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. +- Preserve established TUI behavior unless the task intentionally changes it. +- Load the `opencode-dev` skill before interactively running, debugging, or verifying opencode's V2 CLI, TUI, or server. diff --git a/packages/cli/bin/opencode2.cjs b/packages/cli/bin/opencode2.cjs old mode 100644 new mode 100755 diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 9c8e1e69b5..aa498c8141 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -18,7 +18,6 @@ await rm("dist", { recursive: true, force: true }) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") -const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() const allTargets: { @@ -74,7 +73,7 @@ for (const item of targets) { external: ["node-gyp"], format: "esm", minify: true, - sourcemap: sourcemapsFlag ? "linked" : "none", + sourcemap: "inline", splitting: true, compile: { autoloadBunfig: false, diff --git a/packages/cli/src/mini/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts index 535147e651..0868d3a94b 100644 --- a/packages/cli/src/mini/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -37,7 +37,8 @@ function defaultCost(model: CurrentModel) { export function runAgent(input: CurrentAgent): RunAgent { return { - name: input.id, + id: input.id, + name: input.name, description: input.description, mode: input.mode, hidden: input.hidden, @@ -53,7 +54,7 @@ export function runCommand(input: CurrentCommand): RunCommand { export function runSkill(input: CurrentSkill): RunCommand { return { - name: input.name, + name: input.id, description: input.description, source: "skill", } diff --git a/packages/cli/src/mini/demo.ts b/packages/cli/src/mini/demo.ts index b06b3c4537..ed0b6878f5 100644 --- a/packages/cli/src/mini/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -628,11 +628,11 @@ function emitEdit(state: State): void { function emitPatch(state: State): void { const file = path.join(process.cwd(), "src", "demo-format.ts") - const ref = make(state, "apply_patch", { + const ref = make(state, "patch", { patchText: "*** Begin Patch\n*** End Patch", }) doneTool(state, ref, { - title: "apply_patch", + title: "patch", output: "", metadata: { files: [ diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 6ba7940ab6..4c6214d9a8 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState { .map((item) => ({ kind: "mention", display: "@" + item.name, - value: item.name, + value: item.id, part: { type: "agent", - name: item.name, + name: item.id, source: { start: 0, end: 0, diff --git a/packages/cli/src/mini/footer.ts b/packages/cli/src/mini/footer.ts index ab53e9162e..0a2510f167 100644 --- a/packages/cli/src/mini/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -84,7 +84,6 @@ type RunFooterOptions = { theme: RunTheme keymap: Keymap tuiConfig: RunTuiConfig - backgroundSubagents: boolean diffStyle: RunDiffStyle onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -326,7 +325,6 @@ export class RunFooter implements FooterApi { theme: footer.theme, diffStyle: options.diffStyle, tuiConfig: options.tuiConfig, - backgroundSubagents: options.backgroundSubagents, history: footer.history, agent: options.agentLabel, onSubmit: footer.handlePrompt, diff --git a/packages/cli/src/mini/footer.view.tsx b/packages/cli/src/mini/footer.view.tsx index cabb4ee1c8..a7824dddcf 100644 --- a/packages/cli/src/mini/footer.view.tsx +++ b/packages/cli/src/mini/footer.view.tsx @@ -89,7 +89,6 @@ type RunFooterViewProps = { theme: () => RunTheme diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig - backgroundSubagents: boolean history?: () => RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean @@ -169,9 +168,7 @@ export function RunFooterView(props: RunFooterViewProps) { return tabs().findIndex((item) => item.sessionID === sessionID) + 1 }) - const foregroundSubagents = createMemo( - () => props.backgroundSubagents && activeTabs().some((item) => !item.background), - ) + const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background)) const model = createMemo(() => { const current = props.currentModel() return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined } diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 9259bd5dbe..8a47529816 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,6 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { truthy } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Effect } from "effect" import path from "node:path" @@ -84,9 +83,6 @@ export async function runMini(input: MiniCommandInput) { files: [], initialInput, thinking: true, - backgroundSubagents: - truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") || - (process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")), replay: input.replay ?? true, replayLimit: input.replayLimit, demo: input.demo, diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 97d2fd91b6..bc356eb743 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -1,14 +1,5 @@ -import type { - EventSubscribeOutput, - OpenCodeClient, -} from "@opencode-ai/client/promise" -import type { - ReasoningPart, - StepFinishPart, - StepStartPart, - TextPart, - ToolPart, -} from "@opencode-ai/sdk/v2" +import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2" import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" import { UI } from "./ui" @@ -169,8 +160,8 @@ export async function runNonInteractivePrompt(input: Input) { } } if ( - event.type === "session.execution.settled" && - event.data.outcome === "interrupted" && + event.type === "session.execution.interrupted" && + event.data.reason === "user" && (interrupted || permissionRejected || questionRejected || formCancelled) ) { return @@ -194,11 +185,12 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.text.started") { - starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) + starts.set("text", { id: partID(event.id), timestamp: time }) continue } if (event.type === "session.text.ended") { - const started = starts.get(event.data.textID) + const started = starts.get("text") + starts.delete("text") const part: TextPart = { id: started?.id ?? partID(event.id), sessionID: input.sessionID, @@ -212,18 +204,19 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.reasoning.started") { - starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) + starts.set("reasoning", { id: partID(event.id), timestamp: time }) continue } if (event.type === "session.reasoning.ended" && input.thinking) { - const started = starts.get(event.data.reasoningID) + const started = starts.get("reasoning") + starts.delete("reasoning") const part: ReasoningPart = { id: started?.id ?? partID(event.id), sessionID: input.sessionID, messageID: event.data.assistantMessageID, type: "reasoning", text: event.data.text, - metadata: event.data.providerMetadata, + metadata: event.data.state, time: { start: started?.timestamp ?? time, end: time }, } if (emit("reasoning", time, { part })) continue @@ -261,10 +254,10 @@ export async function runNonInteractivePrompt(input: Input) { id: current?.id ?? partID(event.id), timestamp: current?.timestamp ?? time, assistantMessageID: event.data.assistantMessageID, - tool: event.data.tool, + tool: current?.tool ?? "tool", input: event.data.input, raw: current?.raw, - provider: event.data.provider, + provider: { executed: event.data.executed, state: event.data.state }, }) continue } @@ -288,10 +281,9 @@ export async function runNonInteractivePrompt(input: Input) { metadata: { structured: event.data.structured, content: event.data.content, - outputPaths: event.data.outputPaths, result: event.data.result, providerCall: current.provider, - providerResult: event.data.provider, + providerResult: { executed: event.data.executed, state: event.data.resultState }, rawInput: current.raw, }, time: { start: current.timestamp, end: time }, @@ -318,7 +310,7 @@ export async function runNonInteractivePrompt(input: Input) { metadata: { result: event.data.result, providerCall: current.provider, - providerResult: event.data.provider, + providerResult: { executed: event.data.executed, state: event.data.resultState }, rawInput: current.raw, }, time: { start: current.timestamp, end: time }, @@ -353,16 +345,25 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) continue } - if (event.type === "session.execution.settled") { - if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { + if (event.type === "session.execution.failed") { + if (!emittedError && !questionRejected && !formCancelled) { emittedError = true process.exitCode = 1 - const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } - if (!emit("error", time, { error })) UI.error(error.message) + if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) } - if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 return } + if (event.type === "session.execution.interrupted") { + if (event.data.reason === "user" && interrupted) process.exitCode = 130 + if (event.data.reason !== "user" && !emittedError) { + emittedError = true + process.exitCode = 1 + const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` } + if (!emit("error", time, { error })) UI.error(error.message) + } + return + } + if (event.type === "session.execution.succeeded") return } } diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index d8e3dcd228..a6aedbb97d 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -89,12 +89,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr !explicitModel && !sessionModel ? await client.model .default({ location: { directory: cwd, workspace } }) - .then((result) => - result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined, - ) + .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) - if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (input.variant && !model) + return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) const available = await client.model.list({ location: { directory: cwd, workspace } }) @@ -112,9 +111,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr if (!session && input.title !== undefined) { await client.session.rename({ sessionID: selected.id, - title: - input.title || - prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), + title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), }) } @@ -200,7 +197,7 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`) return } - const agent = agents.find((item) => item.name === name) + const agent = agents.find((item) => item.id === name) if (!agent) { warning(`agent "${name}" not found. Falling back to default agent`) return diff --git a/packages/cli/src/mini/runtime.lifecycle.ts b/packages/cli/src/mini/runtime.lifecycle.ts index 4700f7c479..e25c29f02e 100644 --- a/packages/cli/src/mini/runtime.lifecycle.ts +++ b/packages/cli/src/mini/runtime.lifecycle.ts @@ -64,7 +64,6 @@ export type LifecycleInput = { model: RunInput["model"] variant: string | undefined tuiConfig: RunTuiConfig | Promise - backgroundSubagents: boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -236,7 +235,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { if (state.demo?.permission(next)) { return @@ -876,7 +873,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: files: input.files, initialInput: input.initialInput, thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, @@ -928,7 +924,6 @@ export async function runInteractiveMode( files: input.files, initialInput: input.initialInput, thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index ad34f6ecff..091ff296ee 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -16,7 +16,7 @@ // backgrounding is intentionally absent: subagent jobs block the parent // session, so only whole-session `v2.session.background(parentID)` exists. import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" -import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" import { Locale } from "@opencode-ai/tui/util/locale" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" @@ -35,54 +35,59 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string export function legacyTool(input: { sessionID: string messageID: string - callID: string - name: string - state: SessionMessageAssistantTool["state"] - time: SessionMessageAssistantTool["time"] - provider?: SessionMessageAssistantTool["provider"] + tool: SessionMessageAssistantTool }): ToolPart { + const tool = input.tool + const providerCall = + tool.executed === undefined && tool.providerState === undefined + ? undefined + : { executed: tool.executed, state: tool.providerState } + const providerResult = + tool.executed === undefined && tool.providerResultState === undefined + ? undefined + : { executed: tool.executed, state: tool.providerResultState } const base = { - id: `prt_${input.callID}`, + id: `prt_${tool.id}`, sessionID: input.sessionID, messageID: input.messageID, type: "tool" as const, - callID: input.callID, - tool: input.name, + callID: tool.id, + tool: tool.name, } - if (input.state.status === "pending") { + if (tool.state.status === "streaming") { return { ...base, - state: { status: "pending", input: {}, raw: input.state.input }, + state: { status: "pending", input: {}, raw: tool.state.input }, } } - if (input.state.status === "running") { + if (tool.state.status === "running") { return { ...base, state: { status: "running", - input: input.state.input, - title: input.name, - metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider }, - time: { start: input.time.ran ?? input.time.created }, + input: tool.state.input, + title: tool.name, + metadata: { structured: tool.state.structured, content: tool.state.content, providerCall }, + time: { start: tool.time.ran ?? tool.time.created }, }, } } - if (input.state.status === "completed") { + if (tool.state.status === "completed") { return { ...base, state: { status: "completed", - input: input.state.input, - output: outputText(input.state.content), - title: input.name, + input: tool.state.input, + output: outputText(tool.state.content), + title: tool.name, metadata: { - structured: input.state.structured, - content: input.state.content, - outputPaths: input.state.outputPaths, - result: input.state.result, - providerCall: input.provider, + structured: tool.state.structured, + content: tool.state.content, + result: tool.state.result, + providerCall, + providerResult, }, - time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created }, }, } } @@ -90,15 +95,16 @@ export function legacyTool(input: { ...base, state: { status: "error", - input: input.state.input, - error: input.state.error.message, + input: tool.state.input, + error: tool.state.error.message, metadata: { - structured: input.state.structured, - content: input.state.content, - result: input.state.result, - providerCall: input.provider, + structured: tool.state.structured, + content: tool.state.content, + result: tool.state.result, + providerCall, + providerResult, }, - time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created }, }, } } @@ -138,6 +144,7 @@ type ToolTrack = { name: string input: Record started: number + providerState?: Record } type ChildState = { @@ -171,7 +178,7 @@ export type SubagentTrackerInput = { export type SubagentTracker = { main(event: V2Event): void foreign(sessionID: string, event: V2Event): void - hydrate(next: { messages: SessionMessage[]; active: Record }): Promise + hydrate(next: { messages: SessionMessageInfo[]; active: Record }): Promise select(sessionID: string | undefined): void snapshot(): FooterSubagentState } @@ -225,6 +232,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const hydrationOverflow = new Set() const hydrations = new Map>() let selected: string | undefined + const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}` const ensureChild = (sessionID: string): ChildState => { const existing = children.get(sessionID) @@ -305,13 +313,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const part = legacyTool({ sessionID: child.sessionID, messageID, - callID: item.id, - name: item.name, - state: item.state, - time: item.time, - provider: item.provider, + tool: item, }) - if (item.state.status === "pending") return + if (item.state.status === "streaming") return child.callIDs.add(item.id) if (item.state.status === "running") { setFrame(child, `tool:${item.id}`, toolCommit(part, "start")) @@ -322,7 +326,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac setFrame(child, `tool:${item.id}`, toolCommit(part, "final")) } - const rebuild = (child: ChildState, messages: SessionMessage[]) => { + const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => { child.frames = [] child.text.clear() child.projectedText.clear() @@ -339,31 +343,37 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } if (message.type !== "assistant") continue child.messageIDs.add(message.id) + let textOrdinal = 0 + let reasoningOrdinal = 0 for (const item of message.content) { if (item.type === "text") { - child.text.set(item.id, item.text) - child.projectedText.set(item.id, item.text) - setFrame(child, `text:${item.id}`, { + const id = `text:${textOrdinal++}` + const key = fragmentKey(message.id, id) + child.text.set(key, item.text) + child.projectedText.set(key, item.text) + setFrame(child, key, { kind: "assistant", source: "assistant", text: item.text, phase: "progress", messageID: message.id, - partID: item.id, + partID: id, }) continue } if (item.type === "reasoning") { - child.reasoning.set(item.id, item.text) - child.projectedReasoning.set(item.id, item.text) + const id = `reasoning:${reasoningOrdinal++}` + const key = fragmentKey(message.id, id) + child.reasoning.set(key, item.text) + child.projectedReasoning.set(key, item.text) if (input.thinking) - setFrame(child, `reasoning:${item.id}`, { + setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${item.text}`, phase: "progress", messageID: message.id, - partID: item.id, + partID: id, }) continue } @@ -401,7 +411,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac for (const [id, prompt] of pendingPrompts) { if (!child.prompts.has(id)) child.prompts.set(id, prompt) } - rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[]) + rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[]) for (const [id, tool] of pendingTools) { if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) } @@ -467,74 +477,88 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input.emit() return } + if (event.type === "session.text.started") { + return + } if (event.type === "session.text.delta") { - const projected = child.projectedText.get(event.data.textID) + const id = `text:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + const projected = child.projectedText.get(key) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { - child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length)) + child.projectedText.set(key, projected.slice(covered + event.data.delta.length)) return } - const next = (child.text.get(event.data.textID) ?? "") + event.data.delta - child.text.set(event.data.textID, next) - setFrame(child, `text:${event.data.textID}`, { + const next = (child.text.get(key) ?? "") + event.data.delta + child.text.set(key, next) + setFrame(child, key, { kind: "assistant", source: "assistant", text: next, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.textID, + partID: id, }) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.text.ended") { - child.text.set(event.data.textID, event.data.text) - child.projectedText.delete(event.data.textID) - setFrame(child, `text:${event.data.textID}`, { + const id = `text:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + child.text.set(key, event.data.text) + child.projectedText.delete(key) + setFrame(child, key, { kind: "assistant", source: "assistant", text: event.data.text, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.textID, + partID: id, }) touch(child, event.created) notifyDetail(child) return } + if (event.type === "session.reasoning.started") { + return + } if (event.type === "session.reasoning.delta") { - const projected = child.projectedReasoning.get(event.data.reasoningID) + const id = `reasoning:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + const projected = child.projectedReasoning.get(key) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { - child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length)) + child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length)) return } - const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta - child.reasoning.set(event.data.reasoningID, next) + const next = (child.reasoning.get(key) ?? "") + event.data.delta + child.reasoning.set(key, next) if (!input.thinking) return - setFrame(child, `reasoning:${event.data.reasoningID}`, { + setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${next}`, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.reasoningID, + partID: id, }) notifyDetail(child) return } if (event.type === "session.reasoning.ended") { - child.reasoning.set(event.data.reasoningID, event.data.text) - child.projectedReasoning.delete(event.data.reasoningID) + const id = `reasoning:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + child.reasoning.set(key, event.data.text) + child.projectedReasoning.delete(key) if (!input.thinking) return - setFrame(child, `reasoning:${event.data.reasoningID}`, { + setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${event.data.text}`, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.reasoningID, + partID: id, }) notifyDetail(child) return @@ -548,17 +572,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) child.tools.set(event.data.callID, { - name: event.data.tool, + name: current?.name ?? "tool", input: event.data.input, started: current?.started ?? event.created, + providerState: event.data.state, }) childTool( child, structuredClone({ type: "tool", id: event.data.callID, - name: event.data.tool, - provider: event.data.provider, + name: current?.name ?? "tool", + executed: event.data.executed, + providerState: event.data.state, state: { status: "running", input: event.data.input, structured: {}, content: [] }, time: { created: current?.started ?? event.created, ran: event.created }, }) as SessionMessageAssistantTool, @@ -578,7 +604,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac type: "tool", id: event.data.callID, name: current?.name ?? "tool", - provider: event.data.provider, + executed: event.data.executed, + providerState: current?.providerState, + providerResultState: event.data.resultState, state: failed ? { status: "error", @@ -593,7 +621,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input: current?.input ?? {}, structured: event.data.structured, content: event.data.content, - outputPaths: event.data.outputPaths, result: event.data.result, }, time: { @@ -608,6 +635,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } + if (event.type === "session.step.ended") return if (event.type === "session.step.failed") { setFrame(child, `error:step:${event.data.assistantMessageID}`, { kind: "error", @@ -620,9 +648,23 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "session.execution.settled") { + if (event.type === "session.execution.started") { + child.status = "running" + touch(child, event.created) + input.emit() + return + } + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) { child.status = - event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" + event.type === "session.execution.succeeded" + ? "completed" + : event.type === "session.execution.interrupted" + ? "cancelled" + : "error" touch(child, event.created) input.emit() } @@ -644,8 +686,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac return { main(event) { + if (event.type === "session.tool.input.started") { + if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {}) + return + } if (event.type === "session.tool.called") { - if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) + if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input) return } if (event.type === "session.tool.failed") { diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index 44e413ce52..ae854ef2e3 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -1,12 +1,8 @@ -import type { - EventSubscribeOutput, - OpenCodeClient, -} from "@opencode-ai/client/promise" +import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, - SessionMessage, - SessionMessageAssistant, + SessionMessageInfo, SessionMessageAssistantTool, } from "@opencode-ai/sdk/v2" import { Event } from "@opencode-ai/schema/event" @@ -101,6 +97,7 @@ type ToolState = { input: Record started: number running: boolean + providerState?: Record } type State = { @@ -264,8 +261,7 @@ function shellTerminal( : shell.status === "exited" ? `Shell exited with code ${shell.exit ?? "unknown"}` : `Shell ${shell.status}` - if (!error) - return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })] + if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })] return [ ...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []), shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }), @@ -310,9 +306,7 @@ async function resolveSelectedModel(input: StreamInput, next: Pick response.model) if (session) return { ...session, variant: next.variant } - const fallback = await input.sdk.model - .default(undefined, { signal: next.signal }) - .then((response) => response.data) + const fallback = await input.sdk.model.default(undefined, { signal: next.signal }).then((response) => response.data) if (!fallback) return return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } } @@ -393,13 +387,9 @@ export async function createSessionTransport(input: StreamInput): Promise { + const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => { if (message.type === "user") { const waiting = state.wait?.messageID === message.id if (waiting && state.wait) state.wait.promoted = true @@ -447,41 +438,44 @@ export async function createSessionTransport(input: StreamInput): Promise= 0) { @@ -643,13 +642,14 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) write([ @@ -659,15 +659,19 @@ export async function createSessionTransport(input: StreamInput): Promise= 0) { @@ -684,13 +688,14 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) write([ @@ -700,7 +705,7 @@ export async function createSessionTransport(input: StreamInput): Promise 0 ? total.toLocaleString() : "" write([], { - phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage, }) return @@ -802,21 +808,33 @@ export async function createSessionTransport(input: StreamInput): Promise processEffect(options).pipe( Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))), Effect.provide(NodeServices.layer), ), ) @@ -37,13 +37,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { - if (options.mode === "service") { - const service = yield* ServiceConfig.options() - yield* Flock.effect(path.basename(service.file, ".json") + "-process", { - dir: path.dirname(service.file), - staleMs: 3_000, - timeoutMs: 15_000, - }) + const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined + const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file) + if ( + serviceOptions !== undefined && + lockScope !== undefined && + (yield* Service.discover(serviceOptions)) !== undefined + ) { + yield* Scope.close(lockScope, Exit.void) + return } const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. @@ -64,7 +66,10 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { port: Option.fromNullishOr(options.port ?? config.port), password, }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) - if (options.mode === "service") yield* register(address, password) + if (lockScope !== undefined) { + yield* register(address, password) + yield* Scope.close(lockScope, Exit.void) + } const url = HttpServer.formatAddress(address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) @@ -75,6 +80,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) }) +const acquireServiceLock = Effect.fnUntraced(function* (file: string) { + const flock = yield* EffectFlock.Service + const scope = yield* Scope.make() + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)) + yield* flock + .acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 }) + .pipe(Effect.provideService(Scope.Scope, scope)) + return scope +}) + // The latest atomic registration wins. A displaced process notices the new id, // exits, and cannot remove its successor's registration from its finalizer. const infoJson = Schema.fromJsonString(Service.Info) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 68073851c1..ac1f9ac1cf 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,7 +1,8 @@ import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { Global } from "@opencode-ai/core/global" import { expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -24,3 +25,47 @@ test("local channel stores service config with the local service filename", asyn await fs.rm(root, { recursive: true, force: true }) } }) + +test("concurrent service processes elect one server", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-")) + const env = { + ...process.env, + HOME: root, + OPENCODE_DB: path.join(root, "opencode.db"), + OPENCODE_TEST_HOME: root, + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + } + const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"] + const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + + try { + const registration = path.join(root, "state", "opencode", "service-local.json") + const info = await waitForInfo(registration) + const winner = info.pid === first.pid ? first : second + const loser = info.pid === first.pid ? second : first + const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)]) + + expect(exited).toBe(true) + expect(winner.exitCode).toBe(null) + } finally { + first.kill("SIGTERM") + second.kill("SIGTERM") + await Promise.all([first.exited, second.exited]) + await fs.rm(root, { recursive: true, force: true }) + } +}) + +async function waitForInfo(file: string) { + for (let attempt = 0; attempt < 200; attempt++) { + const value = await Bun.file(file) + .json() + .catch(() => undefined) + if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value) + await Bun.sleep(50) + } + throw new Error("Timed out waiting for service registration") +} diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 6a83d52b99..70f1dcc587 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -3,15 +3,14 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from import { ClientApi, effectOmitEndpoints, - endpointNames, groupNames, promiseOmitEndpoints, } from "@opencode-ai/protocol/client" import { Effect } from "effect" import { fileURLToPath } from "url" -const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) -const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) +const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints }) +const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints }) await Effect.runPromise( Effect.all( diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 319a9f72e2..8fd9994f5c 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -1,7 +1,6 @@ export { ClientApi, effectOmitEndpoints, - endpointNames, groupNames, promiseOmitEndpoints, } from "@opencode-ai/protocol/client" diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 76f243c3dc..0c7b7a3af3 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -74,196 +74,216 @@ export type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params" export type Endpoint4_3Output = EffectValue>["data"] export type SessionGetOperation = (input: Endpoint4_3Input) => Effect.Effect -type Endpoint4_4Request = Parameters[0] -export type Endpoint4_4Input = { - readonly sessionID: Endpoint4_4Request["params"]["sessionID"] - readonly messageID?: Endpoint4_4Request["payload"]["messageID"] -} -export type Endpoint4_4Output = EffectValue>["data"] -export type SessionForkOperation = (input: Endpoint4_4Input) => Effect.Effect +type Endpoint4_4Request = Parameters[0] +export type Endpoint4_4Input = { readonly sessionID: Endpoint4_4Request["params"]["sessionID"] } +export type Endpoint4_4Output = EffectValue> +export type SessionRemoveOperation = (input: Endpoint4_4Input) => Effect.Effect -type Endpoint4_5Request = Parameters[0] +type Endpoint4_5Request = Parameters[0] export type Endpoint4_5Input = { readonly sessionID: Endpoint4_5Request["params"]["sessionID"] - readonly agent: Endpoint4_5Request["payload"]["agent"] + readonly messageID?: Endpoint4_5Request["payload"]["messageID"] } -export type Endpoint4_5Output = EffectValue> -export type SessionSwitchAgentOperation = (input: Endpoint4_5Input) => Effect.Effect +export type Endpoint4_5Output = EffectValue>["data"] +export type SessionForkOperation = (input: Endpoint4_5Input) => Effect.Effect -type Endpoint4_6Request = Parameters[0] +type Endpoint4_6Request = Parameters[0] export type Endpoint4_6Input = { readonly sessionID: Endpoint4_6Request["params"]["sessionID"] - readonly model: Endpoint4_6Request["payload"]["model"] + readonly agent: Endpoint4_6Request["payload"]["agent"] } -export type Endpoint4_6Output = EffectValue> -export type SessionSwitchModelOperation = (input: Endpoint4_6Input) => Effect.Effect +export type Endpoint4_6Output = EffectValue> +export type SessionSwitchAgentOperation = (input: Endpoint4_6Input) => Effect.Effect -type Endpoint4_7Request = Parameters[0] +type Endpoint4_7Request = Parameters[0] export type Endpoint4_7Input = { readonly sessionID: Endpoint4_7Request["params"]["sessionID"] - readonly title: Endpoint4_7Request["payload"]["title"] + readonly model: Endpoint4_7Request["payload"]["model"] } -export type Endpoint4_7Output = EffectValue> -export type SessionRenameOperation = (input: Endpoint4_7Input) => Effect.Effect +export type Endpoint4_7Output = EffectValue> +export type SessionSwitchModelOperation = (input: Endpoint4_7Input) => Effect.Effect -type Endpoint4_8Request = Parameters[0] +type Endpoint4_8Request = Parameters[0] export type Endpoint4_8Input = { readonly sessionID: Endpoint4_8Request["params"]["sessionID"] - readonly id?: Endpoint4_8Request["payload"]["id"] - readonly prompt: Endpoint4_8Request["payload"]["prompt"] - readonly delivery?: Endpoint4_8Request["payload"]["delivery"] - readonly resume?: Endpoint4_8Request["payload"]["resume"] + readonly title: Endpoint4_8Request["payload"]["title"] } -export type Endpoint4_8Output = EffectValue>["data"] -export type SessionPromptOperation = (input: Endpoint4_8Input) => Effect.Effect +export type Endpoint4_8Output = EffectValue> +export type SessionRenameOperation = (input: Endpoint4_8Input) => Effect.Effect -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] export type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly id?: Endpoint4_9Request["payload"]["id"] - readonly command: Endpoint4_9Request["payload"]["command"] - readonly arguments?: Endpoint4_9Request["payload"]["arguments"] - readonly agent?: Endpoint4_9Request["payload"]["agent"] - readonly model?: Endpoint4_9Request["payload"]["model"] - readonly files?: Endpoint4_9Request["payload"]["files"] - readonly agents?: Endpoint4_9Request["payload"]["agents"] - readonly delivery?: Endpoint4_9Request["payload"]["delivery"] - readonly resume?: Endpoint4_9Request["payload"]["resume"] + readonly destination: Endpoint4_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] } -export type Endpoint4_9Output = EffectValue>["data"] -export type SessionCommandOperation = (input: Endpoint4_9Input) => Effect.Effect +export type Endpoint4_9Output = EffectValue> +export type SessionMoveOperation = (input: Endpoint4_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Request = Parameters[0] export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly id?: Endpoint4_10Request["payload"]["id"] - readonly skill: Endpoint4_10Request["payload"]["skill"] + readonly prompt: Endpoint4_10Request["payload"]["prompt"] + readonly delivery?: Endpoint4_10Request["payload"]["delivery"] readonly resume?: Endpoint4_10Request["payload"]["resume"] } -export type Endpoint4_10Output = EffectValue> -export type SessionSkillOperation = (input: Endpoint4_10Input) => Effect.Effect +export type Endpoint4_10Output = EffectValue>["data"] +export type SessionPromptOperation = (input: Endpoint4_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Request = Parameters[0] export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] - readonly text: Endpoint4_11Request["payload"]["text"] - readonly description?: Endpoint4_11Request["payload"]["description"] - readonly metadata?: Endpoint4_11Request["payload"]["metadata"] + readonly id?: Endpoint4_11Request["payload"]["id"] + readonly command: Endpoint4_11Request["payload"]["command"] + readonly arguments?: Endpoint4_11Request["payload"]["arguments"] + readonly agent?: Endpoint4_11Request["payload"]["agent"] + readonly model?: Endpoint4_11Request["payload"]["model"] + readonly files?: Endpoint4_11Request["payload"]["files"] + readonly agents?: Endpoint4_11Request["payload"]["agents"] + readonly delivery?: Endpoint4_11Request["payload"]["delivery"] + readonly resume?: Endpoint4_11Request["payload"]["resume"] } -export type Endpoint4_11Output = EffectValue> -export type SessionSyntheticOperation = (input: Endpoint4_11Input) => Effect.Effect +export type Endpoint4_11Output = EffectValue>["data"] +export type SessionCommandOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] readonly id?: Endpoint4_12Request["payload"]["id"] - readonly command: Endpoint4_12Request["payload"]["command"] + readonly skill: Endpoint4_12Request["payload"]["skill"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } -export type Endpoint4_12Output = EffectValue> -export type SessionShellOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -export type Endpoint4_13Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_13Input) => Effect.Effect +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { + readonly sessionID: Endpoint4_13Request["params"]["sessionID"] + readonly text: Endpoint4_13Request["payload"]["text"] + readonly description?: Endpoint4_13Request["payload"]["description"] + readonly metadata?: Endpoint4_13Request["payload"]["metadata"] + readonly resume?: Endpoint4_13Request["payload"]["resume"] +} +export type Endpoint4_13Output = EffectValue> +export type SessionSyntheticOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -export type Endpoint4_14Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_14Input) => Effect.Effect +type Endpoint4_14Request = Parameters[0] +export type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly id?: Endpoint4_14Request["payload"]["id"] + readonly command: Endpoint4_14Request["payload"]["command"] +} +export type Endpoint4_14Output = EffectValue> +export type SessionShellOperation = (input: Endpoint4_14Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] - readonly messageID: Endpoint4_15Request["payload"]["messageID"] - readonly files?: Endpoint4_15Request["payload"]["files"] + readonly id?: Endpoint4_15Request["payload"]["id"] } -export type Endpoint4_15Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_15Input) => Effect.Effect +export type Endpoint4_15Output = EffectValue>["data"] +export type SessionCompactOperation = (input: Endpoint4_15Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_16Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] -export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_17Input) => Effect.Effect +type Endpoint4_17Request = Parameters[0] +export type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly messageID: Endpoint4_17Request["payload"]["messageID"] + readonly files?: Endpoint4_17Request["payload"]["files"] +} +export type Endpoint4_17Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_17Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_18Input) => Effect.Effect +export type Endpoint4_18Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -export type Endpoint4_19Output = EffectValue< +export type Endpoint4_19Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_19Input) => Effect.Effect + +type Endpoint4_20Request = Parameters[0] +export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } +export type Endpoint4_20Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_20Input) => Effect.Effect + +type Endpoint4_21Request = Parameters[0] +export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } +export type Endpoint4_21Output = EffectValue< ReturnType >["data"] export type SessionInstructionsEntryListOperation = ( - input: Endpoint4_19Input, -) => Effect.Effect - -type Endpoint4_20Request = Parameters[0] -export type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly key: Endpoint4_20Request["params"]["key"] - readonly value: Endpoint4_20Request["payload"]["value"] -} -export type Endpoint4_20Output = EffectValue> -export type SessionInstructionsEntryPutOperation = ( - input: Endpoint4_20Input, -) => Effect.Effect - -type Endpoint4_21Request = Parameters[0] -export type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly key: Endpoint4_21Request["params"]["key"] -} -export type Endpoint4_21Output = EffectValue< - ReturnType -> -export type SessionInstructionsEntryRemoveOperation = ( input: Endpoint4_21Input, ) => Effect.Effect -type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Request = Parameters[0] export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] - readonly after?: Endpoint4_22Request["query"]["after"] - readonly follow?: Endpoint4_22Request["query"]["follow"] + readonly key: Endpoint4_22Request["params"]["key"] + readonly value: Endpoint4_22Request["payload"]["value"] } -export type Endpoint4_22Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_22Input) => Stream.Stream +export type Endpoint4_22Output = EffectValue> +export type SessionInstructionsEntryPutOperation = ( + input: Endpoint4_22Input, +) => Effect.Effect -type Endpoint4_23Request = Parameters[0] -export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } -export type Endpoint4_23Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_23Input) => Effect.Effect - -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } -export type Endpoint4_24Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_24Input) => Effect.Effect - -type Endpoint4_25Request = Parameters[0] -export type Endpoint4_25Input = { - readonly sessionID: Endpoint4_25Request["params"]["sessionID"] - readonly messageID: Endpoint4_25Request["params"]["messageID"] +type Endpoint4_23Request = Parameters[0] +export type Endpoint4_23Input = { + readonly sessionID: Endpoint4_23Request["params"]["sessionID"] + readonly key: Endpoint4_23Request["params"]["key"] } -export type Endpoint4_25Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_25Input) => Effect.Effect +export type Endpoint4_23Output = EffectValue< + ReturnType +> +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint4_23Input, +) => Effect.Effect + +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { + readonly sessionID: Endpoint4_24Request["params"]["sessionID"] + readonly after?: Endpoint4_24Request["query"]["after"] + readonly follow?: Endpoint4_24Request["query"]["follow"] +} +export type Endpoint4_24Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint4_24Input) => Stream.Stream + +type Endpoint4_25Request = Parameters[0] +export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } +export type Endpoint4_25Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_25Input) => Effect.Effect + +type Endpoint4_26Request = Parameters[0] +export type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } +export type Endpoint4_26Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_26Input) => Effect.Effect + +type Endpoint4_27Request = Parameters[0] +export type Endpoint4_27Input = { + readonly sessionID: Endpoint4_27Request["params"]["sessionID"] + readonly messageID: Endpoint4_27Request["params"]["messageID"] +} +export type Endpoint4_27Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_27Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation readonly create: SessionCreateOperation readonly active: SessionActiveOperation readonly get: SessionGetOperation + readonly remove: SessionRemoveOperation readonly fork: SessionForkOperation readonly switchAgent: SessionSwitchAgentOperation readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation + readonly move: SessionMoveOperation readonly prompt: SessionPromptOperation readonly command: SessionCommandOperation readonly skill: SessionSkillOperation @@ -271,9 +291,11 @@ export interface SessionApi { readonly shell: SessionShellOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation - readonly revertStage: SessionRevertStageOperation - readonly revertClear: SessionRevertClearOperation - readonly revertCommit: SessionRevertCommitOperation + readonly revert: { + readonly stage: SessionRevertStageOperation + readonly clear: SessionRevertClearOperation + readonly commit: SessionRevertCommitOperation + } readonly context: SessionContextOperation readonly instructions: { readonly entry: { @@ -418,11 +440,15 @@ export type IntegrationAttemptCancelOperation = ( export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation - readonly connectKey: IntegrationConnectKeyOperation - readonly connectOauth: IntegrationConnectOauthOperation - readonly attemptStatus: IntegrationAttemptStatusOperation - readonly attemptComplete: IntegrationAttemptCompleteOperation - readonly attemptCancel: IntegrationAttemptCancelOperation + readonly connect: { + readonly key: IntegrationConnectKeyOperation + readonly oauth: IntegrationConnectOauthOperation + } + readonly attempt: { + readonly status: IntegrationAttemptStatusOperation + readonly complete: IntegrationAttemptCompleteOperation + readonly cancel: IntegrationAttemptCancelOperation + } } type Endpoint10_0Request = Parameters[0] @@ -430,8 +456,16 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query export type Endpoint10_0Output = EffectValue> export type ServerMcpListOperation = (input?: Endpoint10_0Input) => Effect.Effect +type Endpoint10_1Request = Parameters[0] +export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +export type Endpoint10_1Output = EffectValue> +export type ServerMcpResourceCatalogOperation = ( + input?: Endpoint10_1Input, +) => Effect.Effect + export interface ServerMcpApi { readonly list: ServerMcpListOperation + readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation } } type Endpoint11_0Request = Parameters[0] @@ -481,7 +515,7 @@ export interface ProjectApi { type Endpoint13_0Request = Parameters[0] export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } export type Endpoint13_0Output = EffectValue> -export type FormListRequestsOperation = (input?: Endpoint13_0Input) => Effect.Effect +export type FormRequestListOperation = (input?: Endpoint13_0Input) => Effect.Effect type Endpoint13_1Request = Parameters[0] export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } @@ -535,7 +569,7 @@ export type Endpoint13_6Output = EffectValue = (input: Endpoint13_6Input) => Effect.Effect export interface FormApi { - readonly listRequests: FormListRequestsOperation + readonly request: { readonly list: FormRequestListOperation } readonly list: FormListOperation readonly create: FormCreateOperation readonly get: FormGetOperation @@ -547,7 +581,7 @@ export interface FormApi { type Endpoint14_0Request = Parameters[0] export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } export type Endpoint14_0Output = EffectValue> -export type PermissionListRequestsOperation = ( +export type PermissionRequestListOperation = ( input?: Endpoint14_0Input, ) => Effect.Effect @@ -556,14 +590,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer export type Endpoint14_1Output = EffectValue< ReturnType >["data"] -export type PermissionListSavedOperation = ( +export type PermissionSavedListOperation = ( input?: Endpoint14_1Input, ) => Effect.Effect type Endpoint14_2Request = Parameters[0] export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } export type Endpoint14_2Output = EffectValue> -export type PermissionRemoveSavedOperation = ( +export type PermissionSavedRemoveOperation = ( input: Endpoint14_2Input, ) => Effect.Effect @@ -611,9 +645,8 @@ export type Endpoint14_6Output = EffectValue = (input: Endpoint14_6Input) => Effect.Effect export interface PermissionApi { - readonly listRequests: PermissionListRequestsOperation - readonly listSaved: PermissionListSavedOperation - readonly removeSaved: PermissionRemoveSavedOperation + readonly request: { readonly list: PermissionRequestListOperation } + readonly saved: { readonly list: PermissionSavedListOperation; readonly remove: PermissionSavedRemoveOperation } readonly create: PermissionCreateOperation readonly list: PermissionListOperation readonly get: PermissionGetOperation @@ -729,7 +762,7 @@ export type Endpoint20_1Input = { readonly location?: Endpoint20_1Request["query"]["location"] readonly command: Endpoint20_1Request["payload"]["command"] readonly cwd?: Endpoint20_1Request["payload"]["cwd"] - readonly timeout?: Endpoint20_1Request["payload"]["timeout"] + readonly timeout: Endpoint20_1Request["payload"]["timeout"] readonly metadata?: Endpoint20_1Request["payload"]["metadata"] } export type Endpoint20_1Output = EffectValue> @@ -743,28 +776,38 @@ export type Endpoint20_2Input = { export type Endpoint20_2Output = EffectValue> export type ShellGetOperation = (input: Endpoint20_2Input) => Effect.Effect -type Endpoint20_3Request = Parameters[0] +type Endpoint20_3Request = Parameters[0] export type Endpoint20_3Input = { readonly id: Endpoint20_3Request["params"]["id"] readonly location?: Endpoint20_3Request["query"]["location"] - readonly cursor?: Endpoint20_3Request["query"]["cursor"] - readonly limit?: Endpoint20_3Request["query"]["limit"] + readonly timeout: Endpoint20_3Request["payload"]["timeout"] } -export type Endpoint20_3Output = EffectValue> -export type ShellOutputOperation = (input: Endpoint20_3Input) => Effect.Effect +export type Endpoint20_3Output = EffectValue> +export type ShellTimeoutOperation = (input: Endpoint20_3Input) => Effect.Effect -type Endpoint20_4Request = Parameters[0] +type Endpoint20_4Request = Parameters[0] export type Endpoint20_4Input = { readonly id: Endpoint20_4Request["params"]["id"] readonly location?: Endpoint20_4Request["query"]["location"] + readonly cursor?: Endpoint20_4Request["query"]["cursor"] + readonly limit?: Endpoint20_4Request["query"]["limit"] } -export type Endpoint20_4Output = EffectValue> -export type ShellRemoveOperation = (input: Endpoint20_4Input) => Effect.Effect +export type Endpoint20_4Output = EffectValue> +export type ShellOutputOperation = (input: Endpoint20_4Input) => Effect.Effect + +type Endpoint20_5Request = Parameters[0] +export type Endpoint20_5Input = { + readonly id: Endpoint20_5Request["params"]["id"] + readonly location?: Endpoint20_5Request["query"]["location"] +} +export type Endpoint20_5Output = EffectValue> +export type ShellRemoveOperation = (input: Endpoint20_5Input) => Effect.Effect export interface ShellApi { readonly list: ShellListOperation readonly create: ShellCreateOperation readonly get: ShellGetOperation + readonly timeout: ShellTimeoutOperation readonly output: ShellOutputOperation readonly remove: ShellRemoveOperation } @@ -772,7 +815,7 @@ export interface ShellApi { type Endpoint21_0Request = Parameters[0] export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } export type Endpoint21_0Output = EffectValue> -export type QuestionListRequestsOperation = ( +export type QuestionRequestListOperation = ( input?: Endpoint21_0Input, ) => Effect.Effect @@ -799,7 +842,7 @@ export type Endpoint21_3Output = EffectValue = (input: Endpoint21_3Input) => Effect.Effect export interface QuestionApi { - readonly listRequests: QuestionListRequestsOperation + readonly request: { readonly list: QuestionRequestListOperation } readonly list: QuestionListOperation readonly reply: QuestionReplyOperation readonly reject: QuestionRejectOperation @@ -869,10 +912,15 @@ export interface VcsApi { } export type Endpoint25_0Output = EffectValue> -export type DebugLocationOperation = () => Effect.Effect +export type DebugLocationListOperation = () => Effect.Effect + +type Endpoint25_1Request = Parameters[0] +export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +export type Endpoint25_1Output = EffectValue> +export type DebugLocationEvictOperation = (input?: Endpoint25_1Input) => Effect.Effect export interface DebugApi { - readonly location: DebugLocationOperation + readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } } export interface AppApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 514f70a923..5175567f45 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -95,56 +95,73 @@ const Endpoint4_3 = (raw: RawClient["server.session"]) => (input: Endpoint4_3Inp Effect.map((value) => value.data), ) -type Endpoint4_4Request = Parameters[0] -type Endpoint4_4Input = { - readonly sessionID: Endpoint4_4Request["params"]["sessionID"] - readonly messageID?: Endpoint4_4Request["payload"]["messageID"] -} +type Endpoint4_4Request = Parameters[0] +type Endpoint4_4Input = { readonly sessionID: Endpoint4_4Request["params"]["sessionID"] } const Endpoint4_4 = (raw: RawClient["server.session"]) => (input: Endpoint4_4Input) => + raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_5Request = Parameters[0] +type Endpoint4_5Input = { + readonly sessionID: Endpoint4_5Request["params"]["sessionID"] + readonly messageID?: Endpoint4_5Request["payload"]["messageID"] +} +const Endpoint4_5 = (raw: RawClient["server.session"]) => (input: Endpoint4_5Input) => raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_5Request = Parameters[0] -type Endpoint4_5Input = { - readonly sessionID: Endpoint4_5Request["params"]["sessionID"] - readonly agent: Endpoint4_5Request["payload"]["agent"] +type Endpoint4_6Request = Parameters[0] +type Endpoint4_6Input = { + readonly sessionID: Endpoint4_6Request["params"]["sessionID"] + readonly agent: Endpoint4_6Request["payload"]["agent"] } -const Endpoint4_5 = (raw: RawClient["server.session"]) => (input: Endpoint4_5Input) => +const Endpoint4_6 = (raw: RawClient["server.session"]) => (input: Endpoint4_6Input) => raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_6Request = Parameters[0] -type Endpoint4_6Input = { - readonly sessionID: Endpoint4_6Request["params"]["sessionID"] - readonly model: Endpoint4_6Request["payload"]["model"] +type Endpoint4_7Request = Parameters[0] +type Endpoint4_7Input = { + readonly sessionID: Endpoint4_7Request["params"]["sessionID"] + readonly model: Endpoint4_7Request["payload"]["model"] } -const Endpoint4_6 = (raw: RawClient["server.session"]) => (input: Endpoint4_6Input) => +const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Input) => raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_7Request = Parameters[0] -type Endpoint4_7Input = { - readonly sessionID: Endpoint4_7Request["params"]["sessionID"] - readonly title: Endpoint4_7Request["payload"]["title"] +type Endpoint4_8Request = Parameters[0] +type Endpoint4_8Input = { + readonly sessionID: Endpoint4_8Request["params"]["sessionID"] + readonly title: Endpoint4_8Request["payload"]["title"] } -const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Input) => +const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) => raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_8Request = Parameters[0] -type Endpoint4_8Input = { - readonly sessionID: Endpoint4_8Request["params"]["sessionID"] - readonly id?: Endpoint4_8Request["payload"]["id"] - readonly prompt: Endpoint4_8Request["payload"]["prompt"] - readonly delivery?: Endpoint4_8Request["payload"]["delivery"] - readonly resume?: Endpoint4_8Request["payload"]["resume"] +type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Input = { + readonly sessionID: Endpoint4_9Request["params"]["sessionID"] + readonly destination: Endpoint4_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] } -const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) => +const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => + raw["session.move"]({ + params: { sessionID: input["sessionID"] }, + payload: { destination: input["destination"], moveChanges: input["moveChanges"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly prompt: Endpoint4_10Request["payload"]["prompt"] + readonly delivery?: Endpoint4_10Request["payload"]["delivery"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] +} +const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, @@ -153,20 +170,20 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp Effect.map((value) => value.data), ) -type Endpoint4_9Request = Parameters[0] -type Endpoint4_9Input = { - readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly id?: Endpoint4_9Request["payload"]["id"] - readonly command: Endpoint4_9Request["payload"]["command"] - readonly arguments?: Endpoint4_9Request["payload"]["arguments"] - readonly agent?: Endpoint4_9Request["payload"]["agent"] - readonly model?: Endpoint4_9Request["payload"]["model"] - readonly files?: Endpoint4_9Request["payload"]["files"] - readonly agents?: Endpoint4_9Request["payload"]["agents"] - readonly delivery?: Endpoint4_9Request["payload"]["delivery"] - readonly resume?: Endpoint4_9Request["payload"]["resume"] +type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly id?: Endpoint4_11Request["payload"]["id"] + readonly command: Endpoint4_11Request["payload"]["command"] + readonly arguments?: Endpoint4_11Request["payload"]["arguments"] + readonly agent?: Endpoint4_11Request["payload"]["agent"] + readonly model?: Endpoint4_11Request["payload"]["model"] + readonly files?: Endpoint4_11Request["payload"]["files"] + readonly agents?: Endpoint4_11Request["payload"]["agents"] + readonly delivery?: Endpoint4_11Request["payload"]["delivery"] + readonly resume?: Endpoint4_11Request["payload"]["resume"] } -const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => +const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -185,61 +202,73 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp Effect.map((value) => value.data), ) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly id?: Endpoint4_10Request["payload"]["id"] - readonly skill: Endpoint4_10Request["payload"]["skill"] - readonly resume?: Endpoint4_10Request["payload"]["resume"] +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly skill: Endpoint4_12Request["payload"]["skill"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } -const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => +const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_11Request = Parameters[0] -type Endpoint4_11Input = { - readonly sessionID: Endpoint4_11Request["params"]["sessionID"] - readonly text: Endpoint4_11Request["payload"]["text"] - readonly description?: Endpoint4_11Request["payload"]["description"] - readonly metadata?: Endpoint4_11Request["payload"]["metadata"] +type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Input = { + readonly sessionID: Endpoint4_13Request["params"]["sessionID"] + readonly text: Endpoint4_13Request["payload"]["text"] + readonly description?: Endpoint4_13Request["payload"]["description"] + readonly metadata?: Endpoint4_13Request["payload"]["metadata"] + readonly resume?: Endpoint4_13Request["payload"]["resume"] } -const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => +const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, - payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + payload: { + text: input["text"], + description: input["description"], + metadata: input["metadata"], + resume: input["resume"], + }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly id?: Endpoint4_12Request["payload"]["id"] - readonly command: Endpoint4_12Request["payload"]["command"] +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly id?: Endpoint4_14Request["payload"]["id"] + readonly command: Endpoint4_14Request["payload"]["command"] } -const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] -type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => - raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] - readonly messageID: Endpoint4_15Request["payload"]["messageID"] - readonly files?: Endpoint4_15Request["payload"]["files"] + readonly id?: Endpoint4_15Request["payload"]["id"] } const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => + raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly messageID: Endpoint4_17Request["payload"]["messageID"] + readonly files?: Endpoint4_17Request["payload"]["files"] +} +const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -248,61 +277,61 @@ const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15I Effect.map((value) => value.data), ) -type Endpoint4_16Request = Parameters[0] -type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly key: Endpoint4_20Request["params"]["key"] - readonly value: Endpoint4_20Request["payload"]["value"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly key: Endpoint4_22Request["params"]["key"] + readonly value: Endpoint4_22Request["payload"]["value"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly key: Endpoint4_21Request["params"]["key"] +type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Input = { + readonly sessionID: Endpoint4_23Request["params"]["sessionID"] + readonly key: Endpoint4_23Request["params"]["key"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { - readonly sessionID: Endpoint4_22Request["params"]["sessionID"] - readonly after?: Endpoint4_22Request["query"]["after"] - readonly follow?: Endpoint4_22Request["query"]["follow"] +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { + readonly sessionID: Endpoint4_24Request["params"]["sessionID"] + readonly after?: Endpoint4_24Request["query"]["after"] + readonly follow?: Endpoint4_24Request["query"]["follow"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -313,22 +342,22 @@ const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22I ), ) -type Endpoint4_23Request = Parameters[0] -type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } -const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => +type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } +const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => +type Endpoint4_26Request = Parameters[0] +type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } +const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_25Request = Parameters[0] -type Endpoint4_25Input = { - readonly sessionID: Endpoint4_25Request["params"]["sessionID"] - readonly messageID: Endpoint4_25Request["params"]["messageID"] +type Endpoint4_27Request = Parameters[0] +type Endpoint4_27Input = { + readonly sessionID: Endpoint4_27Request["params"]["sessionID"] + readonly messageID: Endpoint4_27Request["params"]["messageID"] } -const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => +const Endpoint4_27 = (raw: RawClient["server.session"]) => (input: Endpoint4_27Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -339,26 +368,26 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ create: Endpoint4_1(raw), active: Endpoint4_2(raw), get: Endpoint4_3(raw), - fork: Endpoint4_4(raw), - switchAgent: Endpoint4_5(raw), - switchModel: Endpoint4_6(raw), - rename: Endpoint4_7(raw), - prompt: Endpoint4_8(raw), - command: Endpoint4_9(raw), - skill: Endpoint4_10(raw), - synthetic: Endpoint4_11(raw), - shell: Endpoint4_12(raw), - compact: Endpoint4_13(raw), - wait: Endpoint4_14(raw), - revertStage: Endpoint4_15(raw), - revertClear: Endpoint4_16(raw), - revertCommit: Endpoint4_17(raw), - context: Endpoint4_18(raw), - instructions: { entry: { list: Endpoint4_19(raw), put: Endpoint4_20(raw), remove: Endpoint4_21(raw) } }, - log: Endpoint4_22(raw), - interrupt: Endpoint4_23(raw), - background: Endpoint4_24(raw), - message: Endpoint4_25(raw), + remove: Endpoint4_4(raw), + fork: Endpoint4_5(raw), + switchAgent: Endpoint4_6(raw), + switchModel: Endpoint4_7(raw), + rename: Endpoint4_8(raw), + move: Endpoint4_9(raw), + prompt: Endpoint4_10(raw), + command: Endpoint4_11(raw), + skill: Endpoint4_12(raw), + synthetic: Endpoint4_13(raw), + shell: Endpoint4_14(raw), + compact: Endpoint4_15(raw), + wait: Endpoint4_16(raw), + revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) }, + context: Endpoint4_20(raw), + instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, + log: Endpoint4_24(raw), + interrupt: Endpoint4_25(raw), + background: Endpoint4_26(raw), + message: Endpoint4_27(raw), }) type Endpoint5_0Request = Parameters[0] @@ -505,11 +534,8 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_ const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ list: Endpoint9_0(raw), get: Endpoint9_1(raw), - connectKey: Endpoint9_2(raw), - connectOauth: Endpoint9_3(raw), - attemptStatus: Endpoint9_4(raw), - attemptComplete: Endpoint9_5(raw), - attemptCancel: Endpoint9_6(raw), + connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) }, + attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) }, }) type Endpoint10_0Request = Parameters[0] @@ -517,7 +543,15 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) => raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) }) +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) => + raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ + list: Endpoint10_0(raw), + resource: { catalog: Endpoint10_1(raw) }, +}) type Endpoint11_0Request = Parameters[0] type Endpoint11_0Input = { @@ -654,7 +688,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu ) const adaptGroup13 = (raw: RawClient["server.form"]) => ({ - listRequests: Endpoint13_0(raw), + request: { list: Endpoint13_0(raw) }, list: Endpoint13_1(raw), create: Endpoint13_2(raw), get: Endpoint13_3(raw), @@ -742,9 +776,8 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14 }).pipe(Effect.mapError(mapClientError)) const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint14_0(raw), - listSaved: Endpoint14_1(raw), - removeSaved: Endpoint14_2(raw), + request: { list: Endpoint14_0(raw) }, + saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) }, create: Endpoint14_3(raw), list: Endpoint14_4(raw), get: Endpoint14_5(raw), @@ -877,7 +910,7 @@ type Endpoint20_1Input = { readonly location?: Endpoint20_1Request["query"]["location"] readonly command: Endpoint20_1Request["payload"]["command"] readonly cwd?: Endpoint20_1Request["payload"]["cwd"] - readonly timeout?: Endpoint20_1Request["payload"]["timeout"] + readonly timeout: Endpoint20_1Request["payload"]["timeout"] readonly metadata?: Endpoint20_1Request["payload"]["metadata"] } const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) => @@ -896,25 +929,38 @@ const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Inp Effect.mapError(mapClientError), ) -type Endpoint20_3Request = Parameters[0] +type Endpoint20_3Request = Parameters[0] type Endpoint20_3Input = { readonly id: Endpoint20_3Request["params"]["id"] readonly location?: Endpoint20_3Request["query"]["location"] - readonly cursor?: Endpoint20_3Request["query"]["cursor"] - readonly limit?: Endpoint20_3Request["query"]["limit"] + readonly timeout: Endpoint20_3Request["payload"]["timeout"] } const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) => + raw["shell.timeout"]({ + params: { id: input["id"] }, + query: { location: input["location"] }, + payload: { timeout: input["timeout"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint20_4Request = Parameters[0] +type Endpoint20_4Input = { + readonly id: Endpoint20_4Request["params"]["id"] + readonly location?: Endpoint20_4Request["query"]["location"] + readonly cursor?: Endpoint20_4Request["query"]["cursor"] + readonly limit?: Endpoint20_4Request["query"]["limit"] +} +const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => raw["shell.output"]({ params: { id: input["id"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_4Request = Parameters[0] -type Endpoint20_4Input = { - readonly id: Endpoint20_4Request["params"]["id"] - readonly location?: Endpoint20_4Request["query"]["location"] +type Endpoint20_5Request = Parameters[0] +type Endpoint20_5Input = { + readonly id: Endpoint20_5Request["params"]["id"] + readonly location?: Endpoint20_5Request["query"]["location"] } -const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => +const Endpoint20_5 = (raw: RawClient["server.shell"]) => (input: Endpoint20_5Input) => raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) @@ -923,8 +969,9 @@ const adaptGroup20 = (raw: RawClient["server.shell"]) => ({ list: Endpoint20_0(raw), create: Endpoint20_1(raw), get: Endpoint20_2(raw), - output: Endpoint20_3(raw), - remove: Endpoint20_4(raw), + timeout: Endpoint20_3(raw), + output: Endpoint20_4(raw), + remove: Endpoint20_5(raw), }) type Endpoint21_0Request = Parameters[0] @@ -963,7 +1010,7 @@ const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3 ) const adaptGroup21 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint21_0(raw), + request: { list: Endpoint21_0(raw) }, list: Endpoint21_1(raw), reply: Endpoint21_2(raw), reject: Endpoint21_3(raw), @@ -1043,7 +1090,14 @@ const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(r const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)) -const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) +type Endpoint25_1Request = Parameters[0] +type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) => + raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ + location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) }, +}) const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e042859b91..cc1b6b1854 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -13,6 +13,8 @@ import type { SessionActiveOutput, SessionGetInput, SessionGetOutput, + SessionRemoveInput, + SessionRemoveOutput, SessionForkInput, SessionForkOutput, SessionSwitchAgentInput, @@ -21,6 +23,8 @@ import type { SessionSwitchModelOutput, SessionRenameInput, SessionRenameOutput, + SessionMoveInput, + SessionMoveOutput, SessionPromptInput, SessionPromptOutput, SessionCommandInput, @@ -85,6 +89,8 @@ import type { IntegrationAttemptCancelOutput, ServerMcpListInput, ServerMcpListOutput, + ServerMcpResourceCatalogInput, + ServerMcpResourceCatalogOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -94,8 +100,8 @@ import type { ProjectCurrentOutput, ProjectDirectoriesInput, ProjectDirectoriesOutput, - FormListRequestsInput, - FormListRequestsOutput, + FormRequestListInput, + FormRequestListOutput, FormListInput, FormListOutput, FormCreateInput, @@ -108,12 +114,12 @@ import type { FormReplyOutput, FormCancelInput, FormCancelOutput, - PermissionListRequestsInput, - PermissionListRequestsOutput, - PermissionListSavedInput, - PermissionListSavedOutput, - PermissionRemoveSavedInput, - PermissionRemoveSavedOutput, + PermissionRequestListInput, + PermissionRequestListOutput, + PermissionSavedListInput, + PermissionSavedListOutput, + PermissionSavedRemoveInput, + PermissionSavedRemoveOutput, PermissionCreateInput, PermissionCreateOutput, PermissionListInput, @@ -149,12 +155,14 @@ import type { ShellCreateOutput, ShellGetInput, ShellGetOutput, + ShellTimeoutInput, + ShellTimeoutOutput, ShellOutputInput, ShellOutputOutput, ShellRemoveInput, ShellRemoveOutput, - QuestionListRequestsInput, - QuestionListRequestsOutput, + QuestionRequestListInput, + QuestionRequestListOutput, QuestionListInput, QuestionListOutput, QuestionReplyInput, @@ -173,7 +181,9 @@ import type { VcsStatusOutput, VcsDiffInput, VcsDiffOutput, - DebugLocationOutput, + DebugLocationListOutput, + DebugLocationEvictInput, + DebugLocationEvictOutput, } from "./types" import { ClientError } from "./client-error" @@ -422,6 +432,17 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/session/${encodeURIComponent(input.sessionID)}`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), fork: (input: SessionForkInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionForkOutput }>( { @@ -470,6 +491,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + move: (input: SessionMoveInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/move`, + body: { destination: input["destination"], moveChanges: input["moveChanges"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionPromptOutput }>( { @@ -521,7 +554,12 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, - body: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + body: { + text: input["text"], + description: input["description"], + metadata: input["metadata"], + resume: input["resume"], + }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, @@ -541,16 +579,17 @@ export function make(options: ClientOptions) { requestOptions, ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => - request( + request<{ readonly data: SessionCompactOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, - successStatus: 204, - declaredStatuses: [404, 409, 503, 500, 400, 401], - empty: true, + body: { id: input["id"] }, + successStatus: 200, + declaredStatuses: [409, 404, 400, 401], + empty: false, }, requestOptions, - ), + ).then((value) => value.data), wait: (input: SessionWaitInput, requestOptions?: RequestOptions) => request( { @@ -562,40 +601,42 @@ export function make(options: ClientOptions) { }, requestOptions, ), - revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionRevertStageOutput }>( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, - body: { messageID: input["messageID"], files: input["files"] }, - successStatus: 200, - declaredStatuses: [404, 409, 500, 400, 401], - empty: false, - }, - requestOptions, - ).then((value) => value.data), - revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, - successStatus: 204, - declaredStatuses: [404, 409, 500, 400, 401], - empty: true, - }, - requestOptions, - ), - revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, - successStatus: 204, - declaredStatuses: [404, 409, 400, 401], - empty: true, - }, - requestOptions, - ), + revert: { + stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionRevertStageOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, + body: { messageID: input["messageID"], files: input["files"] }, + successStatus: 200, + declaredStatuses: [404, 409, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, + successStatus: 204, + declaredStatuses: [404, 409, 500, 400, 401], + empty: true, + }, + requestOptions, + ), + commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, + successStatus: 204, + declaredStatuses: [404, 409, 400, 401], + empty: true, + }, + requestOptions, + ), + }, context: (input: SessionContextInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionContextOutput }>( { @@ -797,69 +838,73 @@ export function make(options: ClientOptions) { }, requestOptions, ), - connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, - query: { location: input["location"] }, - body: { key: input["key"], label: input["label"] }, - successStatus: 204, - declaredStatuses: [400, 401], - empty: true, - }, - requestOptions, - ), - connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, - query: { location: input["location"] }, - body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, - successStatus: 200, - declaredStatuses: [400, 401], - empty: false, - }, - requestOptions, - ), - attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, - query: { location: input["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, - query: { location: input["location"] }, - body: { code: input["code"] }, - successStatus: 204, - declaredStatuses: [400, 401], - empty: true, - }, - requestOptions, - ), - attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, - query: { location: input["location"] }, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + connect: { + key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, + query: { location: input["location"] }, + body: { key: input["key"], label: input["label"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, + query: { location: input["location"] }, + body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + }, + attempt: { + status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + query: { location: input["location"] }, + body: { code: input["code"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, }, "server.mcp": { list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => @@ -874,6 +919,20 @@ export function make(options: ClientOptions) { }, requestOptions, ), + resource: { + catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/mcp/resource`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, }, credential: { update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => @@ -934,18 +993,20 @@ export function make(options: ClientOptions) { ), }, form: { - listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/form/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + request: { + list: (input?: FormRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/form/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, list: (input: FormListInput, requestOptions?: RequestOptions) => request<{ readonly data: FormListOutput }>( { @@ -1023,41 +1084,45 @@ export function make(options: ClientOptions) { ), }, permission: { - listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/permission/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionListSavedOutput }>( - { - method: "GET", - path: `/api/permission/saved`, - query: { projectID: input?.["projectID"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ).then((value) => value.data), - removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/permission/saved/${encodeURIComponent(input.id)}`, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + request: { + list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/permission/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + saved: { + list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionSavedListOutput }>( + { + method: "GET", + path: `/api/permission/saved`, + query: { projectID: input?.["projectID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/permission/saved/${encodeURIComponent(input.id)}`, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, create: (input: PermissionCreateInput, requestOptions?: RequestOptions) => request<{ readonly data: PermissionCreateOutput }>( { @@ -1300,6 +1365,19 @@ export function make(options: ClientOptions) { }, requestOptions, ), + timeout: (input: ShellTimeoutInput, requestOptions?: RequestOptions) => + request( + { + method: "PATCH", + path: `/api/shell/${encodeURIComponent(input.id)}/timeout`, + query: { location: input["location"] }, + body: { timeout: input["timeout"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), output: (input: ShellOutputInput, requestOptions?: RequestOptions) => request( { @@ -1326,18 +1404,20 @@ export function make(options: ClientOptions) { ), }, question: { - listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/question/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + request: { + list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/question/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, list: (input: QuestionListInput, requestOptions?: RequestOptions) => request<{ readonly data: QuestionListOutput }>( { @@ -1454,17 +1534,31 @@ export function make(options: ClientOptions) { ), }, debug: { - location: (requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/debug/location`, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + location: { + list: (requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/debug/location`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/debug/location`, + query: { location: input?.["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 5b3d6c165f..043f2caa3d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -74,14 +74,6 @@ export type SkillNotFoundError = { export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError" -export type SessionBusyError = { - readonly _tag: "SessionBusyError" - readonly sessionID: string - readonly message: string -} -export const isSessionBusyError = (value: unknown): value is SessionBusyError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError" - export type ServiceUnavailableError = { readonly _tag: "ServiceUnavailableError" readonly message: string @@ -90,6 +82,14 @@ export type ServiceUnavailableError = { export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" +export type SessionBusyError = { + readonly _tag: "SessionBusyError" + readonly sessionID: string + readonly message: string +} +export const isSessionBusyError = (value: unknown): value is SessionBusyError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError" + export type UnknownError = { readonly _tag: "UnknownError" readonly message: string @@ -185,6 +185,7 @@ export type AgentListOutput = { } readonly data: ReadonlyArray<{ readonly id: string + readonly name: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly request: { readonly settings: { readonly [x: string]: JsonValue } @@ -326,6 +327,7 @@ export type SessionListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -344,13 +346,12 @@ export type SessionListOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } }> @@ -388,6 +389,7 @@ export type SessionCreateOutput = { readonly data: { readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -406,13 +408,12 @@ export type SessionCreateOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -426,6 +427,7 @@ export type SessionGetOutput = { readonly data: { readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -444,18 +446,21 @@ export type SessionGetOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } }["data"] +export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionRemoveOutput = void + export type SessionForkInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly messageID?: { readonly messageID?: string | undefined }["messageID"] @@ -465,6 +470,7 @@ export type SessionForkOutput = { readonly data: { readonly id: string readonly parentID?: string + readonly fork?: { readonly sessionID: string; readonly messageID?: string } readonly projectID: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } @@ -483,13 +489,12 @@ export type SessionForkOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -518,6 +523,20 @@ export type SessionRenameInput = { export type SessionRenameOutput = void +export type SessionMoveInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly destination: { + readonly destination: { readonly directory: string } + readonly moveChanges?: boolean | undefined + }["destination"] + readonly moveChanges?: { + readonly destination: { readonly directory: string } + readonly moveChanges?: boolean | undefined + }["moveChanges"] +} + +export type SessionMoveOutput = void + export type SessionPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { @@ -848,17 +867,26 @@ export type SessionSyntheticInput = { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["text"] readonly description?: { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["description"] readonly metadata?: { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["metadata"] + readonly resume?: { + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null + }["resume"] } export type SessionSyntheticOutput = void @@ -871,9 +899,21 @@ export type SessionShellInput = { export type SessionShellOutput = void -export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionCompactInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { readonly id?: string | undefined }["id"] +} -export type SessionCompactOutput = void +export type SessionCompactOutput = { + readonly data: { + readonly type: "compaction" + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly timeCreated: number + readonly handledSeq?: number + } +}["data"] export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } @@ -890,13 +930,12 @@ export type SessionRevertStageOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } }["data"] @@ -951,7 +990,6 @@ export type SessionContextOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -968,6 +1006,7 @@ export type SessionContextOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -976,21 +1015,10 @@ export type SessionContextOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1006,25 +1034,22 @@ export type SessionContextOutput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly id: string; readonly text: string } + | { readonly type: "text"; readonly text: string } | { readonly type: "reasoning" - readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly state?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" readonly id: string readonly name: string - readonly provider?: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - } + readonly executed?: boolean + readonly providerState?: { readonly [x: string]: JsonValue } + readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1037,19 +1062,10 @@ export type SessionContextOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1061,19 +1077,14 @@ export type SessionContextOutput = { | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > readonly structured: { readonly [x: string]: JsonValue } - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: string + readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost?: number readonly tokens?: { readonly input: number @@ -1081,17 +1092,44 @@ export type SessionContextOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly error?: { readonly type: "unknown"; readonly message: string } - } - | { - readonly type: "compaction" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } + readonly error?: { readonly type: string; readonly message: string } + readonly retry?: { + readonly attempt: number + readonly at: number + readonly error: { readonly type: string; readonly message: string } + } } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) > }["data"] @@ -1129,7 +1167,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.agent.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly agent: string } } @@ -1138,7 +1176,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.model.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1150,7 +1188,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.moved" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1163,16 +1201,25 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.renamed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.deleted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 2 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.forked" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } @@ -1181,7 +1228,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.promoted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -1190,7 +1237,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1213,12 +1260,51 @@ export type SessionLogOutput = readonly delivery: "steer" | "queue" } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.succeeded" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly error: { readonly type: string; readonly message: string } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.interrupted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.instructions.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } @@ -1227,7 +1313,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.synthetic" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1241,16 +1327,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.skill.activated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } + readonly data: { + readonly sessionID: string + readonly id: string + readonly name: string + readonly text: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1273,7 +1364,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1302,7 +1393,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1317,12 +1408,12 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly finish: string + readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost: number readonly tokens: { readonly input: number @@ -1339,12 +1430,19 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } } | { @@ -1352,21 +1450,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly textID: string + readonly ordinal: number readonly text: string } } @@ -1375,13 +1473,13 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly ordinal: number + readonly state?: { readonly [x: string]: unknown } } } | { @@ -1389,14 +1487,14 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string + readonly ordinal: number readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly state?: { readonly [x: string]: unknown } } } | { @@ -1404,7 +1502,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1418,7 +1516,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1432,18 +1530,15 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.called" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly tool: string readonly input: { readonly [x: string]: unknown } - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly state?: { readonly [x: string]: unknown } } } | { @@ -1451,7 +1546,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.progress" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1469,7 +1564,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.success" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1480,12 +1575,9 @@ export type SessionLogOutput = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { @@ -1493,55 +1585,62 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.retried" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.retry.scheduled" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string + readonly assistantMessageID: string readonly attempt: number - readonly error: { - readonly message: string - readonly statusCode?: number - readonly isRetryable: boolean - readonly responseHeaders?: { readonly [x: string]: string } - readonly responseBody?: string - readonly metadata?: { readonly [x: string]: string } - } + readonly at: number + readonly error: { readonly type: string; readonly message: string } } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.compaction.admitted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly inputID: string } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly recent: string + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1550,12 +1649,26 @@ export type SessionLogOutput = readonly recent: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.compaction.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + readonly inputID?: string + } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.staged" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1563,13 +1676,12 @@ export type SessionLogOutput = readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -1579,7 +1691,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.cleared" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1588,9 +1700,9 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.committed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly to: string } } ) | { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number } @@ -1648,7 +1760,6 @@ export type SessionMessageOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1665,6 +1776,7 @@ export type SessionMessageOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1673,21 +1785,10 @@ export type SessionMessageOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1703,25 +1804,22 @@ export type SessionMessageOutput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly id: string; readonly text: string } + | { readonly type: "text"; readonly text: string } | { readonly type: "reasoning" - readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly state?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" readonly id: string readonly name: string - readonly provider?: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - } + readonly executed?: boolean + readonly providerState?: { readonly [x: string]: JsonValue } + readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1734,19 +1832,10 @@ export type SessionMessageOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1758,19 +1847,14 @@ export type SessionMessageOutput = { | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > readonly structured: { readonly [x: string]: JsonValue } - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: string + readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost?: number readonly tokens?: { readonly input: number @@ -1778,17 +1862,44 @@ export type SessionMessageOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly error?: { readonly type: "unknown"; readonly message: string } - } - | { - readonly type: "compaction" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } + readonly error?: { readonly type: string; readonly message: string } + readonly retry?: { + readonly attempt: number + readonly at: number + readonly error: { readonly type: string; readonly message: string } + } } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) }["data"] export type MessageListInput = { @@ -1850,7 +1961,6 @@ export type MessageListOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1867,6 +1977,7 @@ export type MessageListOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1875,21 +1986,10 @@ export type MessageListOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1905,25 +2005,22 @@ export type MessageListOutput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly id: string; readonly text: string } + | { readonly type: "text"; readonly text: string } | { readonly type: "reasoning" - readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly state?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" readonly id: string readonly name: string - readonly provider?: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - } + readonly executed?: boolean + readonly providerState?: { readonly [x: string]: JsonValue } + readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1936,19 +2033,10 @@ export type MessageListOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1960,19 +2048,14 @@ export type MessageListOutput = { | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > readonly structured: { readonly [x: string]: JsonValue } - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: string + readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost?: number readonly tokens?: { readonly input: number @@ -1980,17 +2063,44 @@ export type MessageListOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly error?: { readonly type: "unknown"; readonly message: string } - } - | { - readonly type: "compaction" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } + readonly error?: { readonly type: string; readonly message: string } + readonly retry?: { + readonly attempt: number + readonly at: number + readonly error: { readonly type: string; readonly message: string } + } } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) > readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } @@ -2395,6 +2505,36 @@ export type ServerMcpListOutput = { }> } +export type ServerMcpResourceCatalogInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerMcpResourceCatalogOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly resources: ReadonlyArray<{ + readonly server: string + readonly name: string + readonly uri: string + readonly description?: string + readonly mimeType?: string + }> + readonly templates: ReadonlyArray<{ + readonly server: string + readonly name: string + readonly uriTemplate: string + readonly description?: string + readonly mimeType?: string + }> + } +} + export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { @@ -2442,13 +2582,13 @@ export type ProjectDirectoriesInput = { export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> -export type FormListRequestsInput = { +export type FormRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type FormListRequestsOutput = { +export type FormRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3516,13 +3656,13 @@ export type FormCancelInput = { export type FormCancelOutput = void -export type PermissionListRequestsInput = { +export type PermissionRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PermissionListRequestsOutput = { +export type PermissionRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3539,9 +3679,9 @@ export type PermissionListRequestsOutput = { }> } -export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } +export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } -export type PermissionListSavedOutput = { +export type PermissionSavedListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly projectID: string @@ -3550,9 +3690,9 @@ export type PermissionListSavedOutput = { }> }["data"] -export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] } +export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] } -export type PermissionRemoveSavedOutput = void +export type PermissionSavedRemoveOutput = void export type PermissionCreateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -3765,6 +3905,7 @@ export type SkillListOutput = { readonly project: { readonly id: string; readonly directory: string } } readonly data: ReadonlyArray<{ + readonly id: string readonly name: string readonly description?: string readonly slash?: boolean @@ -3820,7 +3961,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.created" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3882,7 +4023,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3944,7 +4085,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4006,7 +4147,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4111,7 +4252,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.removed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string } } @@ -4120,7 +4261,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4359,7 +4500,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.removed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string; readonly partID: string } } @@ -4368,7 +4509,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.agent.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly agent: string } } @@ -4377,7 +4518,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.model.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4389,7 +4530,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.moved" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4402,16 +4543,42 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.renamed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.usage.updated" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.deleted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 2 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.forked" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } @@ -4420,7 +4587,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.promoted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -4429,7 +4596,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4456,20 +4623,44 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.execution.settled" + readonly type: "session.execution.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly sessionID: string - readonly outcome: "success" | "failure" | "interrupted" - readonly error?: { readonly type: "unknown"; readonly message: string } - } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.succeeded" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly error: { readonly type: string; readonly message: string } } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.interrupted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.instructions.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } @@ -4478,7 +4669,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.synthetic" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4492,16 +4683,16 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.skill.activated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } + readonly data: { readonly sessionID: string; readonly id: string; readonly name: string; readonly text: string } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4524,7 +4715,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4553,7 +4744,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4568,12 +4759,12 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly finish: string + readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost: number readonly tokens: { readonly input: number @@ -4590,12 +4781,19 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } } | { @@ -4603,9 +4801,9 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } | { readonly id: string @@ -4616,7 +4814,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly textID: string + readonly ordinal: number readonly delta: string } } @@ -4625,12 +4823,12 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly textID: string + readonly ordinal: number readonly text: string } } @@ -4639,13 +4837,13 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly ordinal: number + readonly state?: { readonly [x: string]: unknown } } } | { @@ -4657,7 +4855,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string + readonly ordinal: number readonly delta: string } } @@ -4666,14 +4864,14 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string + readonly ordinal: number readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly state?: { readonly [x: string]: unknown } } } | { @@ -4681,7 +4879,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4708,7 +4906,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4722,18 +4920,15 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.called" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly tool: string readonly input: { readonly [x: string]: unknown } - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly state?: { readonly [x: string]: unknown } } } | { @@ -4741,7 +4936,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.progress" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4759,7 +4954,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.success" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4770,12 +4965,9 @@ export type EventSubscribeOutput = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { @@ -4783,48 +4975,55 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.retried" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.retry.scheduled" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string + readonly assistantMessageID: string readonly attempt: number - readonly error: { - readonly message: string - readonly statusCode?: number - readonly isRetryable: boolean - readonly responseHeaders?: { readonly [x: string]: string } - readonly responseBody?: string - readonly metadata?: { readonly [x: string]: string } - } + readonly at: number + readonly error: { readonly type: string; readonly message: string } } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.compaction.admitted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly inputID: string } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly recent: string + readonly inputID?: string + } } | { readonly id: string @@ -4839,7 +5038,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4848,12 +5047,26 @@ export type EventSubscribeOutput = readonly recent: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.compaction.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + readonly inputID?: string + } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.staged" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4861,13 +5074,12 @@ export type EventSubscribeOutput = readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -4877,7 +5089,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.cleared" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4886,9 +5098,9 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.committed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly to: string } } | { readonly id: string @@ -5386,6 +5598,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly server: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "mcp.resources.changed" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly server: string } + } | { readonly id: string readonly created: number @@ -5686,25 +5906,25 @@ export type ShellCreateInput = { readonly command: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["command"] readonly cwd?: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["cwd"] - readonly timeout?: { + readonly timeout: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["timeout"] readonly metadata?: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["metadata"] } @@ -5762,6 +5982,37 @@ export type ShellGetOutput = { } } +export type ShellTimeoutInput = { + readonly id: { readonly id: string }["id"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly timeout: { readonly timeout: number }["timeout"] +} + +export type ShellTimeoutOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly command: string + readonly cwd: string + readonly shell: string + readonly file: string + readonly pid?: number + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" + readonly metadata: { readonly [x: string]: JsonValue } + readonly time: { + readonly started: number | "Infinity" | "-Infinity" | "NaN" + readonly completed?: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + export type ShellOutputInput = { readonly id: { readonly id: string }["id"] readonly location?: { @@ -5804,13 +6055,13 @@ export type ShellRemoveInput = { export type ShellRemoveOutput = void -export type QuestionListRequestsInput = { +export type QuestionRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type QuestionListRequestsOutput = { +export type QuestionRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -5968,12 +6219,20 @@ export type VcsDiffOutput = { readonly project: { readonly id: string; readonly directory: string } } readonly data: ReadonlyArray<{ - readonly file?: string - readonly patch?: string + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly status?: "added" | "deleted" | "modified" + readonly status: "added" | "deleted" | "modified" }> } -export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> +export type DebugLocationListOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> + +export type DebugLocationEvictInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type DebugLocationEvictOutput = void diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index a2afc9a002..553c120f7a 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import { Workspace } from "@opencode-ai/schema/workspace" import { Api } from "@opencode-ai/server/api" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" -import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" +import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract" const Client = await import("../src/effect") @@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Directory).toBe(Project.Directory) expect(ProjectV2.Directories).toBe(Project.Directories) expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) - expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) + expect(CoreSessionMessage.Info).toBe(SessionMessage.Info) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) @@ -49,8 +49,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => }) test("client and Server contracts generate identically", () => { - const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) - const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) + const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints }) + const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints }) expect(emitPromise(client)).toEqual(emitPromise(server)) }) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 4c5ea512d7..6247d25610 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -140,6 +140,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => { if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } + if (url.endsWith("/compact")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(compactionAdmission))) + } if (url.includes("/context")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) } @@ -148,10 +151,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { } if (url.endsWith("/api/session/active")) { return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ data: { ses_test: { type: "running" } } }), - ), + HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })), ) } if (request.method === "POST" && url.endsWith("/api/session")) { @@ -161,10 +161,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) } return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ data: [session.data], cursor: { next: "next" } }), - ), + HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })), ) }) const result = await Effect.gen(function* () { @@ -268,6 +265,16 @@ const admission = { }, } +const compactionAdmission = { + data: { + type: "compaction", + admittedSeq: 1, + id: "msg_compaction", + sessionID: "ses_test", + timeCreated: 1_717_171_717_000, + }, +} + const modelSwitchedMessage = { id: "msg_model", type: "model-switched", diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 21b191fd2a..e67d737942 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -33,23 +33,41 @@ test("exposes every standard HTTP API group", () => { "debug", ]) expect(Object.keys(client.debug)).toEqual(["location"]) + expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) expect(Object.keys(client.message)).toEqual(["list"]) - expect(Object.keys(client.integration)).toEqual([ - "list", - "get", - "connectKey", - "connectOauth", - "attemptStatus", - "attemptComplete", - "attemptCancel", - ]) + expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"]) + expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"]) + expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) - expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) + expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("MCP resource catalog uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }, + }) + }, + }) + + const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } }) + + expect(result.data.resources[0]?.uri).toBe("docs://readme") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject") +}) + test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ @@ -240,10 +258,10 @@ test("session methods use the public HTTP contract", async () => { }) } if (url.includes("/prompt")) return Response.json(admission) + if (url.endsWith("/compact")) return Response.json(compactionAdmission) if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) - if (url.endsWith("/api/session/active")) - return Response.json({ data: { ses_test: { type: "running" } } }) + if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } }) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST") return new Response(null, { status: 204 }) return Response.json({ data: [session.data], cursor: { next: "next" } }) @@ -364,6 +382,16 @@ const admission = { }, } +const compactionAdmission = { + data: { + type: "compaction", + admittedSeq: 1, + id: "msg_compaction", + sessionID: "ses_test", + timeCreated: 1_717_171_717_000, + }, +} + const modelSwitchedMessage = { id: "msg_model", type: "model-switched", diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 37bde2d869..e298ccfc57 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -62,7 +62,7 @@ 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. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. +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. ## API @@ -237,11 +237,11 @@ A host cannot define its own `$codemode` top-level namespace. CodeMode executes a deliberately bounded JavaScript subset. It supports: -- Plain data literals, property access, assignment, and destructuring. -- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. +- Plain data literals, property access, assignment, destructuring, and sequence expressions (the comma operator, evaluated left to right with the final value returned). +- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. -- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations, including primitive-number `valueOf`, the standard non-finite `Number` constants, and host-backed `Math.random`. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 9a307f2200..c388cca1bd 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -155,12 +155,10 @@ current omissions to implement, not intentional product boundaries. collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed. -- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate - and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable. +- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set composition methods, and `Array.prototype.toSpliced`. -- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm - helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime. +- [ ] Decide whether iterable `Math.sumPrecise` belongs in the runtime. - [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter defects are distinguishable without leaking private causes. diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 0869a89cb2..e26538550c 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -30,7 +30,6 @@ export type Binding = { export type StatementResult = | { kind: "none" } - | { kind: "value"; value: unknown } | { kind: "return"; value: unknown } | { kind: "break" } | { kind: "continue" } @@ -45,6 +44,7 @@ export class CodeModeFunction { readonly parameters: ReadonlyArray, readonly body: AstNode, readonly capturedScopes: ReadonlyArray>, + readonly async: boolean, ) {} } @@ -153,7 +153,8 @@ export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRunti [supportedSyntaxMessage], ) -export const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null export const asNode = (value: unknown, context: string): AstNode => { if (!isRecord(value) || typeof value.type !== "string") { diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index f34e3949e1..7f5d015d18 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -66,7 +66,7 @@ import { numberMethods, numberStatics, } from "../stdlib/number.js" -import { invokeObjectMethod } from "../stdlib/object.js" +import { invokeObjectMethod, objectMethodsPreservingIdentity } from "../stdlib/object.js" import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" import { escapeRegexHint, @@ -530,17 +530,29 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u if (args[0] instanceof SandboxURLSearchParams) { return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) } - const source = boundedData(args[0], "Array.from input") + 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) + 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) @@ -606,26 +618,26 @@ class Interpreter { // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array - private lastValue: unknown // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore // Fiber-backed promises whose settlement no program construct has observed yet. Successful // program completion drains these (like a runtime waiting on in-flight work at exit) and // surfaces a never-awaited failure as an unhandled-rejection diagnostic. - private readonly pendingSettlements = new Set() + private readonly pendingSettlements: Set constructor( invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, logs: Array = [], + shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set }, ) { const globalScope = new Map() this.scopes = [globalScope] this.invokeTool = invokeTool this.toolKeys = toolKeys this.logs = logs - this.lastValue = undefined - this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("undefined", { mutable: false, value: undefined }) @@ -669,13 +681,15 @@ class Interpreter { return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined - let returned = false - for (const statement of program.body) { + for (const [index, statement] of program.body.entries()) { + if (index === program.body.length - 1 && statement.type === "ExpressionStatement") { + value = yield* self.evaluateExpression(getNode(statement, "expression")) + break + } const result = yield* self.evaluateStatement(statement) if (result.kind === "return") { value = result.value - returned = true break } @@ -683,11 +697,7 @@ class Interpreter { throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) } - if (result.kind === "value") { - self.lastValue = result.value - } } - if (!returned) value = self.lastValue // The program body runs inside an implicit async function, so a returned promise // resolves before crossing the data boundary - `return tools.ns.tool(...)` works @@ -705,15 +715,17 @@ class Interpreter { private drainPendingSettlements(): Effect.Effect { const self = this return Effect.gen(function* () { - for (const promise of [...self.pendingSettlements]) { + while (self.pendingSettlements.size > 0) { + const promise = self.pendingSettlements.values().next().value + if (promise === undefined) break const exit = yield* self.observePromise(promise) if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue const failure = normalizeError(Cause.squash(exit.cause)) throw new InterpreterRuntimeError( - `Unhandled rejection from an un-awaited tool call: ${failure.message}`, + `Unhandled rejection from an un-awaited promise: ${failure.message}`, undefined, failure.kind, - ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], + ["Await promises so failures can be caught and handled."], ) } }) @@ -727,17 +739,15 @@ class Interpreter { path: ReadonlyArray, args: Array, ): Effect.Effect { - const self = this - return Effect.map( - Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { - startImmediately: true, - }), - (fiber) => { - const promise = new SandboxPromise(fiber) - self.pendingSettlements.add(promise) - return promise - }, - ) + return this.createPromise(this.callPermits.withPermit(Effect.suspend(() => this.invokeTool(path, args)))) + } + + private createPromise(effect: Effect.Effect): Effect.Effect { + return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => { + const promise = new SandboxPromise(fiber) + this.pendingSettlements.add(promise) + return promise + }) } // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. @@ -778,7 +788,7 @@ class Interpreter { private evaluateStatement(node: AstNode): Effect.Effect { switch (node.type) { case "ExpressionStatement": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + return Effect.as(this.evaluateExpression(getNode(node, "expression")), { kind: "none" }) case "VariableDeclaration": return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) case "ReturnStatement": { @@ -831,11 +841,6 @@ class Interpreter { const statement = asNode(statementValue, "body") const result = yield* self.evaluateStatement(statement) - if (result.kind === "value") { - self.lastValue = result.value - continue - } - if (result.kind !== "none") { return result } @@ -858,6 +863,7 @@ class Interpreter { getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), getNode(node, "body"), this.scopes.slice(), + node.async === true, ) } @@ -926,7 +932,6 @@ class Interpreter { const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) if (result.kind === "break") return { kind: "none" } satisfies StatementResult if (result.kind === "return" || result.kind === "continue") return result - if (result.kind === "value") self.lastValue = result.value } } return { kind: "none" } satisfies StatementResult @@ -954,9 +959,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } return { kind: "none" } satisfies StatementResult @@ -984,9 +986,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } while (yield* self.evaluateExpression(testNode)) return { kind: "none" } satisfies StatementResult @@ -1042,10 +1041,6 @@ class Interpreter { return { kind: "none" } satisfies StatementResult } - if (result.kind === "value") { - self.lastValue = result.value - } - if (iterationScope) { const loopScope = self.currentScope() for (const name of perIterationBindings) { @@ -1085,7 +1080,7 @@ class Interpreter { } let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined - let assignmentName: string | undefined + let assignment: AstNode | undefined if (left.type === "VariableDeclaration") { const declarations = getArray(left, "declarations") @@ -1095,8 +1090,13 @@ class Interpreter { const declarator = asNode(declarations[0], "declarations[0]") declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } - } else if (left.type === "Identifier") { - assignmentName = getString(left, "name") + } else if ( + left.type === "Identifier" || + left.type === "MemberExpression" || + left.type === "ArrayPattern" || + left.type === "ObjectPattern" + ) { + assignment = left } else { throw new InterpreterRuntimeError("Unsupported for...of binding.", left) } @@ -1105,8 +1105,8 @@ class Interpreter { if (declaration) { self.pushScope() yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) - } else if (assignmentName) { - self.setIdentifierValue(assignmentName, value, left) + } else if (assignment) { + yield* self.assignPattern(assignment, value, left) } const result = yield* self.evaluateStatement(body).pipe( @@ -1125,10 +1125,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -1218,10 +1214,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -1504,6 +1496,16 @@ class Interpreter { return this.evaluateUnaryExpression(node) case "AssignmentExpression": return this.evaluateAssignmentExpression(node) + case "SequenceExpression": { + const self = this + return Effect.gen(function* () { + let result: unknown + for (const expression of getArray(node, "expressions")) { + result = yield* self.evaluateExpression(asNode(expression, "expressions")) + } + return result + }) + } case "CallExpression": return this.evaluateCallExpression(node) case "ArrowFunctionExpression": @@ -2010,9 +2012,12 @@ class Interpreter { if (callable instanceof GlobalMethodReference) { if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) if (callable.namespace === "Object" && args[0] instanceof ToolReference) { - return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + return self.invokeObjectMethodOnTools(callable.name, args[0], node) } - if (callable.namespace === "Object" && callable.name === "assign") { + if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { + return invokeGlobalMethod(callable, args, node) + } + if (callable.namespace === "Array" && (callable.name === "from" || callable.name === "of")) { return invokeGlobalMethod(callable, args, node) } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) @@ -2033,8 +2038,8 @@ class Interpreter { // 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. Every other Object helper cannot produce data from a tool reference, so it fails - // with a pointer at the working idioms instead of the generic plain-objects-only message. + // 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") @@ -2226,14 +2231,36 @@ class Interpreter { switch (ref.name) { case "all": { // Mark every promise element observed up-front (Promise.all handles all of its - // members' failures, as in JS), then join in index order; the first failure rejects - // the whole call while unrelated in-flight members keep running. - const settles = items.map((item) => - item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), + // members' failures, as in JS), race their settlements for fail-fast rejection, and + // preserve input order when they all fulfill. Rejected calls keep draining siblings. + const observations = items.map((item, index) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit })) + : Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }), ) return Effect.gen(function* () { + const remaining = [...observations] const values: Array = [] - for (const settle of settles) values.push(yield* settle) + values.length = items.length + while (remaining.length > 0) { + const winner = yield* Effect.raceAll(remaining) + const position = remaining.indexOf(observations[winner.index]) + if (position >= 0) remaining.splice(position, 1) + if (Exit.isSuccess(winner.exit)) { + values[winner.index] = winner.exit.value + continue + } + yield* self.createPromise( + Effect.asVoid( + Effect.forEach( + items, + (item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void), + { concurrency: "unbounded" }, + ), + ), + ) + return yield* self.unwrapPromiseExit(winner.item, winner.exit, node) + } return values }) } @@ -2307,43 +2334,42 @@ class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const self = this - return Effect.suspend(() => { - const savedScopes = self.scopes - self.scopes = [...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 = self.currentScope() - for (const parameter of fn.parameters) { - for (const name of collectPatternNames(parameter)) { - paramScope.set(name, { mutable: true, value: undefined, initialized: false }) - } - } - for (const [index, parameter] of fn.parameters.entries()) { - if (parameter.type === "RestElement") { - yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) - break - } - yield* self.declarePattern(parameter, args[index], true, parameter) - } - - if (fn.body.type === "BlockStatement") { - const result = yield* self.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined - } - - return yield* self.evaluateExpression(fn.body) - }) - return run.pipe( - Effect.ensuring( - Effect.sync(() => { - self.scopes = savedScopes - }), - ), - ) + const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, { + callPermits: this.callPermits, + pendingSettlements: this.pendingSettlements, }) + invocation.scopes = [...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() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* invocation.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* invocation.evaluateStatement(fn.body) + return result.kind === "return" ? result.value : undefined + } + + 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( @@ -2432,13 +2458,19 @@ class Interpreter { 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(yield* apply(match.args), `String.${name} replacer result`)), + coerceToString(boundedData(resolved, `String.${name} replacer result`)), ) end = match.offset + match.match.length } diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index cc8dd0670e..7720f69775 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -1,9 +1,17 @@ export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) export const mathMethods = new Set([ + "random", "max", "min", "abs", + "acos", + "acosh", + "asin", + "asinh", + "atan", + "atan2", + "atanh", "floor", "ceil", "round", @@ -13,14 +21,27 @@ export const mathMethods = new Set([ "cbrt", "pow", "hypot", + "cos", + "cosh", + "sin", + "sinh", + "tan", + "tanh", "log", "log2", "log10", + "log1p", "exp", + "expm1", + "f16round", + "fround", + "clz32", + "imul", ]) export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + if (name === "random") return Math.random() const nums = args.map((arg) => { if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) return arg @@ -33,6 +54,20 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo return Math.min(...nums) case "abs": return Math.abs(a) + case "acos": + return Math.acos(a) + case "acosh": + return Math.acosh(a) + case "asin": + return Math.asin(a) + case "asinh": + return Math.asinh(a) + case "atan": + return Math.atan(a) + case "atan2": + return Math.atan2(a, b) + case "atanh": + return Math.atanh(a) case "floor": return Math.floor(a) case "ceil": @@ -51,14 +86,38 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo return Math.pow(a, b) case "hypot": return Math.hypot(...nums) + case "cos": + return Math.cos(a) + case "cosh": + return Math.cosh(a) + case "sin": + return Math.sin(a) + case "sinh": + return Math.sinh(a) + case "tan": + return Math.tan(a) + case "tanh": + return Math.tanh(a) case "log": return Math.log(a) case "log2": return Math.log2(a) case "log10": return Math.log10(a) + case "log1p": + return Math.log1p(a) case "exp": return Math.exp(a) + case "expm1": + return Math.expm1(a) + case "f16round": + return Math.f16round(a) + case "fround": + return Math.fround(a) + case "clz32": + return Math.clz32(a) + case "imul": + return Math.imul(a, b) } throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) } diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts index 79710e1ad2..02dfa953b4 100644 --- a/packages/codemode/src/stdlib/number.ts +++ b/packages/codemode/src/stdlib/number.ts @@ -1,6 +1,15 @@ -export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) +export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString", "valueOf"]) -export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) +export const numberConstants = new Set([ + "MAX_SAFE_INTEGER", + "MIN_SAFE_INTEGER", + "MAX_VALUE", + "MIN_VALUE", + "EPSILON", + "NaN", + "POSITIVE_INFINITY", + "NEGATIVE_INFINITY", +]) export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) @@ -32,6 +41,9 @@ export const invokeNumberMethod = (value: number, 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 value = boundedData(args[0], `Object.${name} input`) - if (isSandboxValue(value)) return {} - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + const input = args[0] + if (Array.isArray(input)) return input as unknown as Record + if (isSandboxValue(input)) return {} + if (input instanceof SandboxPromise) { + throw new InterpreterRuntimeError( + `Object.${name} received an un-awaited Promise; await it before inspecting the result.`, + node, + "InvalidDataValue", + ) } - return value as Record + if (input === null || typeof input !== "object") { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") + } + const prototype = Object.getPrototypeOf(input) + if (prototype !== null && prototype !== Object.prototype) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") + } + return input as Record } const guardedSet = (out: Record, key: string, item: unknown): void => { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) out[key] = item } + const addEntry = (out: Record, key: unknown, item: unknown): void => { + boundedData(key, "Object.fromEntries key") + boundedData(item, "Object.fromEntries value") + guardedSet(out, coerceToString(key), item) + } switch (name) { - case "keys": { - const value = boundedData(args[0], "Object.keys input") - if (isSandboxValue(value)) return [] - if (Array.isArray(value)) return Object.keys(value) - if (value === null || typeof value !== "object") { - throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) - } - return Object.keys(value) - } + case "keys": + return Object.keys(requireObject()) case "values": return Object.values(requireObject()) case "entries": @@ -55,7 +66,7 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast case "fromEntries": { if (args[0] instanceof SandboxMap) { const out: Record = Object.create(null) - for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item) + for (const [key, item] of args[0].map.entries()) addEntry(out, key, item) return out } if (args[0] instanceof SandboxURLSearchParams) { @@ -63,16 +74,18 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) return out } - const pairs = boundedData(args[0], "Object.fromEntries input") + const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0] if (!Array.isArray(pairs)) { + boundedData(args[0], "Object.fromEntries input") throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) } const out: Record = Object.create(null) for (const pair of pairs) { - if (!Array.isArray(pair)) { - throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) - } - guardedSet(out, String(pair[0]), pair[1]) + const validated = boundedData(pair, "Object.fromEntries entry") + if (validated === null || typeof validated !== "object" || isSandboxValue(validated)) + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node) + const entry = pair as Record + addEntry(out, entry[0], entry[1]) } return out } diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f4ccc61d4c..80ce828414 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -274,6 +274,16 @@ 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. + for (const [key, item] of Object.entries(value)) { + if (Object.hasOwn(copied, key)) continue + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + Reflect.set(copied, key, copyBounded(item, label, depth + 1, seen, true)) + } + } seen.delete(value) return copied } @@ -607,6 +617,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "", "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.", + "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 `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 221b5e07df..16c17ca0c2 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -658,6 +658,9 @@ describe("CodeMode public contract", () => { 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") + expect(instructions).toContain( + "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", + ) expect(instructions).toContain( "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ) @@ -1081,6 +1084,24 @@ describe("CodeMode public contract", () => { expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) }) + test("returns the final top-level expression when return is omitted", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; 2` })) + + expect(result).toStrictEqual({ ok: true, value: 2, toolCalls: [] }) + }) + + test("does not implicitly return expressions nested in control flow", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `if (true) { 2 }` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + + test("returns null when the final top-level statement is not an expression", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; const value = 2` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + test("rejects invalid configuration and discovery limits", async () => { expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index b20a25b25e..c2cd5a1d21 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -9,7 +9,7 @@ "/api/health": { "get": { "tags": [ - "server.health" + "health" ], "operationId": "v2.health.get", "parameters": [], @@ -27,10 +27,23 @@ "enum": [ true ] + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] } }, "required": [ - "healthy" + "healthy", + "version", + "pid" ], "additionalProperties": false } @@ -65,7 +78,7 @@ "/api/location": { "get": { "tags": [ - "server.location" + "location" ], "operationId": "v2.location.get", "parameters": [ @@ -150,7 +163,7 @@ "/api/agent": { "get": { "tags": [ - "server.agent" + "agent" ], "operationId": "v2.agent.list", "parameters": [ @@ -210,7 +223,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/AgentV2.Info" + "$ref": "#/components/schemas/Agent.Info" } } }, @@ -251,7 +264,7 @@ "/api/plugin": { "get": { "tags": [ - "plugins" + "plugin" ], "operationId": "v2.plugin.list", "parameters": [ @@ -352,7 +365,7 @@ "/api/session": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.list", "parameters": [ @@ -568,7 +581,7 @@ }, "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.create", "parameters": [], @@ -582,7 +595,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -679,7 +692,7 @@ "/api/session/active": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.active", "parameters": [], @@ -699,14 +712,10 @@ "$ref": "#/components/schemas/SessionActive" } } - }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" } }, "required": [ - "data", - "watermarks" + "data" ], "additionalProperties": false } @@ -734,14 +743,14 @@ } } }, - "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", "summary": "List active sessions" } }, "/api/session/{sessionID}": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.get", "parameters": [ @@ -769,7 +778,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -820,12 +829,78 @@ }, "description": "Retrieve a session by ID.", "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" } }, "/api/session/{sessionID}/fork": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.fork", "parameters": [ @@ -853,7 +928,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -940,7 +1015,7 @@ "/api/session/{sessionID}/agent": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.switchAgent", "parameters": [ @@ -1027,7 +1102,7 @@ "/api/session/{sessionID}/model": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.switchModel", "parameters": [ @@ -1114,7 +1189,7 @@ "/api/session/{sessionID}/rename": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.rename", "parameters": [ @@ -1198,10 +1273,123 @@ } } }, + "/api/session/{sessionID}/move": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.move", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move a session to another project directory, optionally transferring local changes.", + "summary": "Move session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "moveChanges": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destination" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.prompt", "parameters": [ @@ -1245,7 +1433,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1353,7 +1548,7 @@ "/api/session/{sessionID}/command": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.command", "parameters": [ @@ -1397,7 +1592,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1560,7 +1762,7 @@ "/api/session/{sessionID}/skill": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.skill", "parameters": [ @@ -1675,7 +1877,7 @@ "/api/session/{sessionID}/synthetic": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.synthetic", "parameters": [ @@ -1759,6 +1961,16 @@ }, "metadata": { "type": "object" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1772,12 +1984,12 @@ } } }, - "/api/session/{sessionID}/compact": { + "/api/session/{sessionID}/shell": { "post": { "tags": [ - "sessions" + "session" ], - "operationId": "v2.session.compact", + "operationId": "v2.session.shell", "parameters": [ { "name": "sessionID", @@ -1818,6 +2030,124 @@ } } }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/compact": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.compact", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Compaction" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "SessionNotFoundError", "content": { @@ -1836,44 +2166,52 @@ } }, "409": { - "description": "SessionBusyError", + "description": "ConflictError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionBusyError" - } - } - } - }, - "500": { - "description": "UnknownError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnknownError" - } - } - } - }, - "503": { - "description": "ServiceUnavailableError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableError" + "$ref": "#/components/schemas/ConflictError" } } } } }, - "description": "Compact a session conversation.", - "summary": "Compact session" + "description": "Queue a durable session compaction request.", + "summary": "Compact session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } } }, "/api/session/{sessionID}/wait": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.wait", "parameters": [ @@ -1951,7 +2289,7 @@ "/api/session/{sessionID}/revert/stage": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.stage", "parameters": [ @@ -1979,7 +2317,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -2092,7 +2430,7 @@ "/api/session/{sessionID}/revert/clear": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.clear", "parameters": [ @@ -2179,7 +2517,7 @@ "/api/session/{sessionID}/revert/commit": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.commit", "parameters": [ @@ -2256,7 +2594,7 @@ "/api/session/{sessionID}/context": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.context", "parameters": [ @@ -2286,7 +2624,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } } }, @@ -2353,7 +2691,7 @@ "/api/session/{sessionID}/instructions/entries": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.list", "parameters": [ @@ -2440,7 +2778,7 @@ "/api/session/{sessionID}/instructions/entries/{key}": { "put": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.put", "parameters": [ @@ -2531,7 +2869,7 @@ }, "delete": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.remove", "parameters": [ @@ -2604,10 +2942,10 @@ "summary": "Remove instruction entry" } }, - "/api/session/{sessionID}/log": { + "/api/experimental/session/{sessionID}/log": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.log", "parameters": [ @@ -2809,14 +3147,14 @@ } } }, - "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "description": "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.", "summary": "Read the session log" } }, "/api/session/{sessionID}/interrupt": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.interrupt", "parameters": [ @@ -2884,7 +3222,7 @@ "/api/session/{sessionID}/background": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.background", "parameters": [ @@ -2952,7 +3290,7 @@ "/api/session/{sessionID}/message/{messageID}": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.message", "parameters": [ @@ -2993,7 +3331,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "required": [ @@ -3052,9 +3390,9 @@ "/api/session/{sessionID}/message": { "get": { "tags": [ - "messages" + "session" ], - "operationId": "v2.session.messages", + "operationId": "v2.message.list", "parameters": [ { "name": "sessionID", @@ -3196,7 +3534,7 @@ "/api/model": { "get": { "tags": [ - "models" + "model" ], "operationId": "v2.model.list", "parameters": [ @@ -3256,7 +3594,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" } } }, @@ -3307,7 +3645,7 @@ "/api/model/default": { "get": { "tags": [ - "models" + "model" ], "operationId": "v2.model.default", "parameters": [ @@ -3367,7 +3705,7 @@ "data": { "anyOf": [ { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" }, { "type": "null" @@ -3553,7 +3891,7 @@ "/api/provider": { "get": { "tags": [ - "providers" + "provider" ], "operationId": "v2.provider.list", "parameters": [ @@ -3664,7 +4002,7 @@ "/api/provider/{providerID}": { "get": { "tags": [ - "providers" + "provider" ], "operationId": "v2.provider.get", "parameters": [ @@ -3790,7 +4128,7 @@ "/api/integration": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.list", "parameters": [ @@ -3891,7 +4229,7 @@ "/api/integration/{integrationID}": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.get", "parameters": [ @@ -4004,7 +4342,7 @@ "/api/integration/{integrationID}/connect/key": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.connect.key", "parameters": [ @@ -4126,7 +4464,7 @@ "/api/integration/{integrationID}/connect/oauth": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.connect.oauth", "parameters": [ @@ -4275,7 +4613,7 @@ "/api/integration/attempt/{attemptID}": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.status", "parameters": [ @@ -4379,7 +4717,7 @@ }, "delete": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.cancel", "parameters": [ @@ -4465,7 +4803,7 @@ "/api/integration/attempt/{attemptID}/complete": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.complete", "parameters": [ @@ -4679,10 +5017,108 @@ "summary": "List MCP servers" } }, + "/api/mcp/resource": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.resource.catalog", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Mcp.ResourceCatalog" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve resources and resource templates from connected MCP servers.", + "summary": "List MCP resources" + } + }, "/api/credential/{credentialID}": { "patch": { "tags": [ - "server.credential" + "credential" ], "operationId": "v2.credential.update", "parameters": [ @@ -4785,7 +5221,7 @@ }, "delete": { "tags": [ - "server.credential" + "credential" ], "operationId": "v2.credential.remove", "parameters": [ @@ -4868,10 +5304,57 @@ "summary": "Remove credential" } }, + "/api/project": { + "get": { + "tags": [ + "project" + ], + "operationId": "v2.project.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known projects.", + "summary": "List projects" + } + }, "/api/project/current": { "get": { "tags": [ - "projects" + "project" ], "operationId": "v2.project.current", "parameters": [ @@ -4956,7 +5439,7 @@ "/api/project/{projectID}/directories": { "get": { "tags": [ - "projects" + "project" ], "operationId": "v2.project.directories", "parameters": [ @@ -5049,7 +5532,7 @@ "/api/form/request": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.form.request.list", "parameters": [ @@ -5157,7 +5640,7 @@ "/api/session/{sessionID}/form": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.list", "parameters": [ @@ -5244,7 +5727,7 @@ }, "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.create", "parameters": [ @@ -5357,7 +5840,7 @@ "/api/session/{sessionID}/form/{formID}": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.get", "parameters": [ @@ -5459,7 +5942,7 @@ "/api/session/{sessionID}/form/{formID}/state": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.state", "parameters": [ @@ -5554,7 +6037,7 @@ "/api/session/{sessionID}/form/{formID}/reply": { "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.reply", "parameters": [ @@ -5660,7 +6143,7 @@ "/api/session/{sessionID}/form/{formID}/cancel": { "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.cancel", "parameters": [ @@ -5749,7 +6232,7 @@ "/api/permission/request": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.request.list", "parameters": [ @@ -5850,7 +6333,7 @@ "/api/permission/saved": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.saved.list", "parameters": [ @@ -5922,7 +6405,7 @@ "/api/permission/saved/{id}": { "delete": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.saved.remove", "parameters": [ @@ -5968,7 +6451,7 @@ "/api/session/{sessionID}/permission": { "post": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.create", "parameters": [ @@ -6131,7 +6614,7 @@ }, "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.list", "parameters": [ @@ -6218,7 +6701,7 @@ "/api/session/{sessionID}/permission/{requestID}": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.get", "parameters": [ @@ -6318,7 +6801,7 @@ "/api/session/{sessionID}/permission/{requestID}/reply": { "post": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.reply", "parameters": [ @@ -6769,7 +7252,7 @@ "/api/command": { "get": { "tags": [ - "commands" + "command" ], "operationId": "v2.command.list", "parameters": [ @@ -6829,7 +7312,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CommandV2.Info" + "$ref": "#/components/schemas/Command.Info" } } }, @@ -6870,7 +7353,7 @@ "/api/skill": { "get": { "tags": [ - "skills" + "skill" ], "operationId": "v2.skill.list", "parameters": [ @@ -6930,7 +7413,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SkillV2.Info" + "$ref": "#/components/schemas/Skill.Info" } } }, @@ -6971,7 +7454,7 @@ "/api/event": { "get": { "tags": [ - "events" + "event" ], "operationId": "v2.event.subscribe", "parameters": [], @@ -7108,154 +7591,10 @@ } } }, - "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.", "summary": "Subscribe to events" } }, - "/api/event/changes": { - "get": { - "tags": [ - "events" - ], - "operationId": "v2.event.changes", - "parameters": [], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "event": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/EventLog.ChangeStream" - } - }, - "required": [ - "id", - "event", - "data" - ], - "additionalProperties": false - }, - "x-effect-stream": { - "encoding": "sse", - "causeSchema": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Fail" - ] - }, - "error": { - "not": {} - } - }, - "required": [ - "_tag", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Die" - ] - }, - "defect": {} - }, - "required": [ - "_tag", - "defect" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Interrupt" - ] - }, - "fiberId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "fiberId" - ], - "additionalProperties": false - } - ] - } - }, - "errorSchema": { - "not": {} - }, - "failureEvent": "effect/httpapi/stream/failure" - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - } - }, - "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", - "summary": "Subscribe to change hints" - } - }, "/api/pty": { "get": { "tags": [ @@ -7873,7 +8212,7 @@ "tags": [ "pty" ], - "operationId": "v2.pty.connectToken", + "operationId": "v2.pty.connect.token", "parameters": [ { "name": "ptyID", @@ -8005,7 +8344,6 @@ "pty" ], "operationId": "v2.pty.connect", - "x-websocket": true, "parameters": [ { "name": "ptyID", @@ -8103,7 +8441,8 @@ } }, "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", - "summary": "Connect to PTY session" + "summary": "Connect to PTY session", + "x-websocket": true } }, "/api/shell": { @@ -8326,7 +8665,8 @@ } }, "required": [ - "command" + "command", + "timeout" ], "additionalProperties": false } @@ -8556,6 +8896,151 @@ "summary": "Remove shell command" } }, + "/api/shell/{id}/timeout": { + "patch": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.timeout", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Replace a running shell command's timeout from now, or clear it with zero.", + "summary": "Update shell timeout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "timeout" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/shell/{id}/output": { "get": { "tags": [ @@ -8737,7 +9222,7 @@ "/api/question/request": { "get": { "tags": [ - "session questions" + "question" ], "operationId": "v2.question.request.list", "parameters": [ @@ -8838,7 +9323,7 @@ "/api/session/{sessionID}/question": { "get": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.list", "parameters": [ @@ -8925,7 +9410,7 @@ "/api/session/{sessionID}/question/{requestID}/reply": { "post": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.reply", "parameters": [ @@ -9019,7 +9504,7 @@ "/api/session/{sessionID}/question/{requestID}/reject": { "post": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.reject", "parameters": [ @@ -9715,7 +10200,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -9752,6 +10237,129 @@ "description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", "summary": "VCS diff" } + }, + "/api/debug/location": { + "get": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Location.Ref" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List locations currently loaded by the server.", + "summary": "List loaded locations" + }, + "delete": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.evict", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Dispose the requested location's cached services so its next use boots them fresh.", + "summary": "Evict a loaded location" + } } }, "components": { @@ -9954,12 +10562,15 @@ "$ref": "#/components/schemas/PermissionV2.Rule" } }, - "AgentV2.Info": { + "Agent.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "name": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, @@ -10000,6 +10611,7 @@ }, "required": [ "id", + "name", "request", "mode", "hidden", @@ -10019,6 +10631,46 @@ ], "additionalProperties": false }, + "Money.USD": { + "type": "number" + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, "Location.Ref": { "type": "object", "properties": { @@ -10039,19 +10691,14 @@ ], "additionalProperties": false }, - "File.Diff": { + "FileDiff.Info": { "type": "object", "properties": { - "path": { + "file": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "added", - "modified", - "deleted" - ] + "patch": { + "type": "string" }, "additions": { "type": "integer", @@ -10069,20 +10716,25 @@ } ] }, - "patch": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ - "path", - "status", + "file", + "patch", "additions", "deletions", - "patch" + "status" ], "additionalProperties": false }, - "Revert.State": { + "Session.Revert": { "type": "object", "properties": { "messageID": { @@ -10099,13 +10751,10 @@ "snapshot": { "type": "string" }, - "diff": { - "type": "string" - }, "files": { "type": "array", "items": { - "$ref": "#/components/schemas/File.Diff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10114,7 +10763,7 @@ ], "additionalProperties": false }, - "SessionV2.Info": { + "Session.Info": { "type": "object", "properties": { "id": { @@ -10133,6 +10782,31 @@ } ] }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + }, "projectID": { "type": "string" }, @@ -10143,44 +10817,10 @@ "$ref": "#/components/schemas/Model.Ref" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "time": { "type": "object", @@ -10211,7 +10851,7 @@ "type": "string" }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -10225,32 +10865,15 @@ ], "additionalProperties": false }, - "SessionWatermarks": { - "type": "object", - "patternProperties": { - "^ses": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." - }, "SessionsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" - }, "cursor": { "type": "object", "properties": { @@ -10280,7 +10903,6 @@ }, "required": [ "data", - "watermarks", "cursor" ], "additionalProperties": false @@ -10408,7 +11030,7 @@ ], "additionalProperties": false }, - "Prompt.Source": { + "Prompt.Mention": { "type": "object", "properties": { "start": { @@ -10440,8 +11062,8 @@ "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10455,8 +11077,8 @@ "name": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10488,28 +11110,78 @@ ], "additionalProperties": false }, + "Prompt.Base64": { + "type": "string", + "allOf": [ + { + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + ] + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, "Prompt.FileAttachment": { "type": "object", "properties": { - "uri": { - "type": "string" + "data": { + "$ref": "#/components/schemas/Prompt.Base64" }, "mime": { "type": "string" }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, "name": { "type": "string" }, "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ - "uri", - "mime" + "data", + "mime", + "source" ], "additionalProperties": false }, @@ -10694,26 +11366,57 @@ ], "additionalProperties": false }, - "SessionBusyError": { + "SessionInput.Compaction": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", "enum": [ - "SessionBusyError" + "compaction" + ] + }, + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } ] }, "sessionID": { - "type": "string" + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "message": { - "type": "string" + "timeCreated": { + "type": "number" + }, + "handledSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "_tag", + "type", + "admittedSeq", + "id", "sessionID", - "message" + "timeCreated" ], "additionalProperties": false }, @@ -10746,6 +11449,29 @@ ], "additionalProperties": false }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, "UnknownError": { "type": "object", "properties": { @@ -10775,7 +11501,7 @@ ], "additionalProperties": false }, - "Session.Message.AgentSwitched": { + "Session.Message.AgentSelected": { "type": "object", "properties": { "id": { @@ -10819,7 +11545,7 @@ ], "additionalProperties": false }, - "Session.Message.ModelSwitched": { + "Session.Message.ModelSelected": { "type": "object", "properties": { "id": { @@ -10853,6 +11579,9 @@ }, "model": { "$ref": "#/components/schemas/Model.Ref" + }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" } }, "required": [ @@ -10945,14 +11674,6 @@ ], "additionalProperties": false }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, "text": { "type": "string" }, @@ -10969,7 +11690,6 @@ "required": [ "id", "time", - "sessionID", "text", "type" ], @@ -11051,6 +11771,9 @@ "skill" ] }, + "skill": { + "type": "string" + }, "name": { "type": "string" }, @@ -11062,6 +11785,7 @@ "id", "time", "type", + "skill", "name", "text" ], @@ -11102,23 +11826,105 @@ "shell" ] }, - "callID": { - "type": "string" + "shellID": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] }, "command": { "type": "string" }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, "output": { - "type": "string" + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false } }, "required": [ "id", "time", "type", - "callID", + "shellID", "command", - "output" + "status" ], "additionalProperties": false }, @@ -11131,25 +11937,18 @@ "text" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" } }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "LLM.ProviderMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState": { + "type": "object" }, "Session.Message.Assistant.Reasoning": { "type": "object", @@ -11160,14 +11959,11 @@ "reasoning" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "time": { "type": "object", @@ -11187,18 +11983,17 @@ }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "Session.Message.ToolState.Pending": { + "Session.Message.ToolState.Streaming": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "pending" + "streaming" ] }, "input": { @@ -11308,24 +12103,12 @@ "input": { "type": "object" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, "content": { "type": "array", "items": { "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -11339,14 +12122,11 @@ ], "additionalProperties": false }, - "Session.Error.Unknown": { + "Session.StructuredError": { "type": "object", "properties": { "type": { - "type": "string", - "enum": [ - "unknown" - ] + "type": "string" }, "message": { "type": "string" @@ -11380,7 +12160,7 @@ "type": "object" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {} }, @@ -11408,28 +12188,19 @@ "name": { "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - }, - "resultMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "state": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" }, { "$ref": "#/components/schemas/Session.Message.ToolState.Running" @@ -11453,9 +12224,6 @@ }, "completed": { "type": "number" - }, - "pruned": { - "type": "number" } }, "required": [ @@ -11473,6 +12241,31 @@ ], "additionalProperties": false }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, "Session.Message.Assistant": { "type": "object", "properties": { @@ -11549,50 +12342,27 @@ "additionalProperties": false }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" } }, "required": [ @@ -11605,7 +12375,7 @@ ], "additionalProperties": false }, - "Session.Message.Compaction": { + "Session.Message.Compaction.Running": { "type": "object", "properties": { "type": { @@ -11614,19 +12384,6 @@ "compaction" ] }, - "reason": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "summary": { - "type": "string" - }, - "recent": { - "type": "string" - }, "id": { "type": "string", "allOf": [ @@ -11649,25 +12406,180 @@ "created" ], "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" } }, "required": [ "type", + "id", + "time", + "status", "reason", "summary", - "recent", - "id", - "time" + "recent" ], "additionalProperties": false }, - "Session.Message": { + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.AgentSwitched" + "$ref": "#/components/schemas/Session.Message.Compaction.Running" }, { - "$ref": "#/components/schemas/Session.Message.ModelSwitched" + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Info": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.AgentSelected" + }, + { + "$ref": "#/components/schemas/Session.Message.ModelSelected" }, { "$ref": "#/components/schemas/Session.Message.User" @@ -11697,7 +12609,7 @@ "allOf": [ { "pattern": "^[a-z0-9][a-z0-9._-]*$", - "description": "Context entry key (lowercase alphanumerics plus . _ -)" + "description": "Instruction entry key (lowercase alphanumerics plus . _ -)" } ] }, @@ -11715,7 +12627,7 @@ ], "additionalProperties": false }, - "session.next.agent.switched": { + "session.agent.selected": { "type": "object", "properties": { "id": { @@ -11726,13 +12638,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.agent.switched" + "session.agent.selected" ] }, "durable": { @@ -11750,11 +12665,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -11771,9 +12684,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11782,22 +12692,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "agent": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "agent" ], "additionalProperties": false @@ -11805,12 +12705,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.model.switched": { + "session.model.selected": { "type": "object", "properties": { "id": { @@ -11821,13 +12723,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.model.switched" + "session.model.selected" ] }, "durable": { @@ -11845,11 +12750,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -11866,9 +12769,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11877,22 +12777,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "model": { "$ref": "#/components/schemas/Model.Ref" } }, "required": [ - "timestamp", "sessionID", - "messageID", "model" ], "additionalProperties": false @@ -11900,12 +12790,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.moved": { + "session.moved": { "type": "object", "properties": { "id": { @@ -11916,13 +12808,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.moved" + "session.moved" ] }, "durable": { @@ -11940,11 +12835,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -11961,9 +12854,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11975,12 +12865,11 @@ "location": { "$ref": "#/components/schemas/Location.Ref" }, - "subdirectory": { + "subpath": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "location" ], @@ -11989,12 +12878,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.renamed": { + "session.renamed": { "type": "object", "properties": { "id": { @@ -12005,13 +12896,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.renamed" + "session.renamed" ] }, "durable": { @@ -12029,11 +12923,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12050,9 +12942,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12066,7 +12955,6 @@ } }, "required": [ - "timestamp", "sessionID", "title" ], @@ -12075,12 +12963,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.forked": { + "session.deleted": { "type": "object", "properties": { "id": { @@ -12091,13 +12981,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.forked" + "session.deleted" ] }, "durable": { @@ -12115,11 +13008,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 2 ] } }, @@ -12136,9 +13027,87 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.forked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.forked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -12155,7 +13124,7 @@ } ] }, - "messageID": { + "from": { "type": "string", "allOf": [ { @@ -12165,7 +13134,6 @@ } }, "required": [ - "timestamp", "sessionID", "parentID" ], @@ -12174,12 +13142,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.prompted": { + "session.prompt.promoted": { "type": "object", "properties": { "id": { @@ -12190,13 +13160,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.prompted" + "session.prompt.promoted" ] }, "durable": { @@ -12214,11 +13187,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12235,9 +13206,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12246,7 +13214,97 @@ } ] }, - "messageID": { + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.prompt.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.prompt.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { "type": "string", "allOf": [ { @@ -12266,9 +13324,8 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", + "inputID", "prompt", "delivery" ], @@ -12277,12 +13334,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.prompt.admitted": { + "session.execution.started": { "type": "object", "properties": { "id": { @@ -12293,13 +13352,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.prompt.admitted" + "session.execution.started" ] }, "durable": { @@ -12317,11 +13379,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12338,9 +13398,168 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.succeeded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.succeeded" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -12349,43 +13568,117 @@ } ] }, - "messageID": { - "type": "string", + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "sessionID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.interrupted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.interrupted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", "allOf": [ { - "pattern": "^msg_" + "minimum": 0 } ] }, - "prompt": { - "$ref": "#/components/schemas/Prompt" + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "delivery": { + "reason": { "type": "string", "enum": [ - "steer", - "queue" + "user", + "shutdown", + "superseded" ] } }, "required": [ - "timestamp", "sessionID", - "messageID", - "prompt", - "delivery" + "reason" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.instructions.updated": { + "session.instructions.updated": { "type": "object", "properties": { "id": { @@ -12396,13 +13689,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.instructions.updated" + "session.instructions.updated" ] }, "durable": { @@ -12420,11 +13716,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12441,9 +13735,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12452,22 +13743,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -12475,12 +13756,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.synthetic": { + "session.synthetic": { "type": "object", "properties": { "id": { @@ -12491,13 +13774,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.synthetic" + "session.synthetic" ] }, "durable": { @@ -12515,11 +13801,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12536,9 +13820,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12547,14 +13828,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" }, @@ -12566,9 +13839,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -12576,12 +13847,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.skill.activated": { + "session.skill.activated": { "type": "object", "properties": { "id": { @@ -12592,13 +13865,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.skill.activated" + "session.skill.activated" ] }, "durable": { @@ -12616,11 +13892,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12637,9 +13911,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12648,13 +13919,8 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "id": { + "type": "string" }, "name": { "type": "string" @@ -12664,9 +13930,8 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", + "id", "name", "text" ], @@ -12675,111 +13940,154 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.shell.started": { + "Shell": { "type": "object", "properties": { "id": { "type": "string", "allOf": [ { - "pattern": "^evt_" + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] }, "metadata": { "type": "object" }, - "type": { - "type": "string", - "enum": [ - "session.next.shell.started" - ] - }, - "durable": { + "time": { "type": "object", "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ + "started": { + "anyOf": [ { - "minimum": 0 + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] }, - "version": { - "type": "integer", - "allOf": [ + "completed": { + "anyOf": [ { - "minimum": 1 + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] } }, "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": [ - "timestamp", - "sessionID", - "messageID", - "callID", - "command" + "started" ], "additionalProperties": false } }, "required": [ "id", - "type", - "data" + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" ], "additionalProperties": false }, - "session.next.shell.ended": { + "session.shell.started": { "type": "object", "properties": { "id": { @@ -12790,13 +14098,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.shell.ended" + "session.shell.started" ] }, "durable": { @@ -12814,11 +14125,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12835,9 +14144,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12846,17 +14152,132 @@ } ] }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" + "shell": { + "$ref": "#/components/schemas/Shell" } }, "required": [ - "timestamp", "sessionID", - "callID", + "shell" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.shell.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "shell": { + "$ref": "#/components/schemas/Shell" + }, + "output": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "shell", "output" ], "additionalProperties": false @@ -12864,12 +14285,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.started": { + "session.step.started": { "type": "object", "properties": { "id": { @@ -12880,13 +14303,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.started" + "session.step.started" ] }, "durable": { @@ -12904,11 +14330,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12925,9 +14349,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12955,7 +14376,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "agent", @@ -12966,12 +14386,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.ended": { + "session.step.ended": { "type": "object", "properties": { "id": { @@ -12982,13 +14404,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.ended" + "session.step.ended" ] }, "durable": { @@ -13006,11 +14431,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13027,9 +14450,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13047,47 +14467,21 @@ ] }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "snapshot": { "type": "string" @@ -13100,7 +14494,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "finish", @@ -13112,12 +14505,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.failed": { + "session.step.failed": { "type": "object", "properties": { "id": { @@ -13128,13 +14523,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.failed" + "session.step.failed" ] }, "durable": { @@ -13152,11 +14550,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13173,9 +14569,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13193,11 +14586,16 @@ ] }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "error" @@ -13207,12 +14605,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.text.started": { + "session.text.started": { "type": "object", "properties": { "id": { @@ -13223,13 +14623,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.started" + "session.text.started" ] }, "durable": { @@ -13247,11 +14650,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13268,9 +14669,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13287,27 +14685,33 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID" + "ordinal" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.text.ended": { + "session.text.ended": { "type": "object", "properties": { "id": { @@ -13318,13 +14722,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.ended" + "session.text.ended" ] }, "durable": { @@ -13342,11 +14749,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13363,9 +14768,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13382,18 +14784,22 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID", + "ordinal", "text" ], "additionalProperties": false @@ -13401,12 +14807,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.input.started": { + "Session.Message.ProviderState3": { + "type": "object" + }, + "session.reasoning.started": { "type": "object", "properties": { "id": { @@ -13417,13 +14828,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.started" + "session.reasoning.started" ] }, "durable": { @@ -13441,11 +14855,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13462,9 +14874,217 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState3" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "ordinal" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState4": { + "type": "object" + }, + "session.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.reasoning.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "text": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState4" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "ordinal", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.input.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -13489,7 +15109,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13500,12 +15119,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.input.ended": { + "session.tool.input.ended": { "type": "object", "properties": { "id": { @@ -13516,13 +15137,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.ended" + "session.tool.input.ended" ] }, "durable": { @@ -13540,11 +15164,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13561,9 +15183,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13588,7 +15207,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13599,18 +15217,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata3": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState5": { + "type": "object" }, - "session.next.tool.called": { + "session.tool.called": { "type": "object", "properties": { "id": { @@ -13621,13 +15238,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.called" + "session.tool.called" ] }, "durable": { @@ -13645,11 +15265,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13666,9 +15284,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13688,48 +15303,36 @@ "callID": { "type": "string" }, - "tool": { - "type": "string" - }, "input": { "type": "object" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata3" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState5" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", - "tool", "input", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.progress": { + "session.tool.progress": { "type": "object", "properties": { "id": { @@ -13740,13 +15343,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.progress" + "session.tool.progress" ] }, "durable": { @@ -13764,11 +15370,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13785,9 +15389,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13818,7 +15419,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13830,18 +15430,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata4": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState6": { + "type": "object" }, - "session.next.tool.success": { + "session.tool.success": { "type": "object", "properties": { "id": { @@ -13852,13 +15451,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.success" + "session.tool.success" ] }, "durable": { @@ -13876,11 +15478,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13897,9 +15497,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13928,55 +15525,38 @@ "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata4" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState6" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata5": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState7": { + "type": "object" }, - "session.next.tool.failed": { + "session.tool.failed": { "type": "object", "properties": { "id": { @@ -13987,13 +15567,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.failed" + "session.tool.failed" ] }, "durable": { @@ -14011,11 +15594,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14032,9 +15613,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14055,50 +15633,36 @@ "type": "string" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata5" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState7" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", "error", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata6": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "session.next.reasoning.started": { + "session.retry.scheduled": { "type": "object", "properties": { "id": { @@ -14109,253 +15673,16 @@ } ] }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.reasoning.started" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "assistantMessageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "reasoningID": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata6" - } - }, - "required": [ - "timestamp", - "sessionID", - "assistantMessageID", - "reasoningID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "LLM.ProviderMetadata7": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "session.next.reasoning.ended": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.reasoning.ended" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "assistantMessageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata7" - } - }, - "required": [ - "timestamp", - "sessionID", - "assistantMessageID", - "reasoningID", - "text" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.retry_error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { + "created": { "type": "number" }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - }, - "session.next.retried": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.retried" + "session.retry.scheduled" ] }, "durable": { @@ -14373,11 +15700,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14394,9 +15719,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14405,17 +15727,39 @@ } ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, "attempt": { - "type": "number" + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "error": { - "$ref": "#/components/schemas/session.next.retry_error" + "$ref": "#/components/schemas/Session.StructuredError" } }, "required": [ - "timestamp", "sessionID", + "assistantMessageID", "attempt", + "at", "error" ], "additionalProperties": false @@ -14423,12 +15767,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.compaction.started": { + "session.compaction.admitted": { "type": "object", "properties": { "id": { @@ -14439,13 +15785,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.started" + "session.compaction.admitted" ] }, "durable": { @@ -14463,11 +15812,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14484,9 +15831,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14495,13 +15839,95 @@ } ] }, - "messageID": { + "inputID": { "type": "string", "allOf": [ { "pattern": "^msg_" } ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, "reason": { "type": "string", @@ -14509,25 +15935,37 @@ "auto", "manual" ] + }, + "recent": { + "type": "string" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ - "timestamp", "sessionID", - "messageID", - "reason" + "reason", + "recent" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.compaction.ended": { + "session.compaction.ended": { "type": "object", "properties": { "id": { @@ -14538,13 +15976,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.ended" + "session.compaction.ended" ] }, "durable": { @@ -14562,11 +16003,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14583,9 +16022,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14594,14 +16030,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "reason": { "type": "string", "enum": [ @@ -14617,9 +16045,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "reason", "text", "recent" @@ -14629,12 +16055,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.revert.staged": { + "session.compaction.failed": { "type": "object", "properties": { "id": { @@ -14645,13 +16073,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.revert.staged" + "session.compaction.failed" ] }, "durable": { @@ -14669,11 +16100,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14690,9 +16119,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14701,175 +16127,17 @@ } ] }, - "revert": { - "$ref": "#/components/schemas/Revert.State" - } - }, - "required": [ - "timestamp", - "sessionID", - "revert" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.revert.cleared": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.revert.cleared" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { + "reason": { "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - } - }, - "required": [ - "timestamp", - "sessionID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.revert.committed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.revert.committed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } + "enum": [ + "auto", + "manual" ] }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + "error": { + "$ref": "#/components/schemas/Session.StructuredError" }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { + "inputID": { "type": "string", "allOf": [ { @@ -14879,114 +16147,393 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID" + "reason", + "error" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "SessionDurableEvent": { + "session.revert.staged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.staged" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "revert": { + "$ref": "#/components/schemas/Session.Revert" + } + }, + "required": [ + "sessionID", + "revert" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.cleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.cleared" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.committed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.committed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "to": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "to" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "Session.Event.Durable": { "oneOf": [ { - "$ref": "#/components/schemas/session.next.agent.switched" + "$ref": "#/components/schemas/session.agent.selected" }, { - "$ref": "#/components/schemas/session.next.model.switched" + "$ref": "#/components/schemas/session.model.selected" }, { - "$ref": "#/components/schemas/session.next.moved" + "$ref": "#/components/schemas/session.moved" }, { - "$ref": "#/components/schemas/session.next.renamed" + "$ref": "#/components/schemas/session.renamed" }, { - "$ref": "#/components/schemas/session.next.forked" + "$ref": "#/components/schemas/session.deleted" }, { - "$ref": "#/components/schemas/session.next.prompted" + "$ref": "#/components/schemas/session.forked" }, { - "$ref": "#/components/schemas/session.next.prompt.admitted" + "$ref": "#/components/schemas/session.prompt.promoted" }, { - "$ref": "#/components/schemas/session.next.instructions.updated" + "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.next.synthetic" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.next.skill.activated" + "$ref": "#/components/schemas/session.execution.succeeded" }, { - "$ref": "#/components/schemas/session.next.shell.started" + "$ref": "#/components/schemas/session.execution.failed" }, { - "$ref": "#/components/schemas/session.next.shell.ended" + "$ref": "#/components/schemas/session.execution.interrupted" }, { - "$ref": "#/components/schemas/session.next.step.started" + "$ref": "#/components/schemas/session.instructions.updated" }, { - "$ref": "#/components/schemas/session.next.step.ended" + "$ref": "#/components/schemas/session.synthetic" }, { - "$ref": "#/components/schemas/session.next.step.failed" + "$ref": "#/components/schemas/session.skill.activated" }, { - "$ref": "#/components/schemas/session.next.text.started" + "$ref": "#/components/schemas/session.shell.started" }, { - "$ref": "#/components/schemas/session.next.text.ended" + "$ref": "#/components/schemas/session.shell.ended" }, { - "$ref": "#/components/schemas/session.next.tool.input.started" + "$ref": "#/components/schemas/session.step.started" }, { - "$ref": "#/components/schemas/session.next.tool.input.ended" + "$ref": "#/components/schemas/session.step.ended" }, { - "$ref": "#/components/schemas/session.next.tool.called" + "$ref": "#/components/schemas/session.step.failed" }, { - "$ref": "#/components/schemas/session.next.tool.progress" + "$ref": "#/components/schemas/session.text.started" }, { - "$ref": "#/components/schemas/session.next.tool.success" + "$ref": "#/components/schemas/session.text.ended" }, { - "$ref": "#/components/schemas/session.next.tool.failed" + "$ref": "#/components/schemas/session.reasoning.started" }, { - "$ref": "#/components/schemas/session.next.reasoning.started" + "$ref": "#/components/schemas/session.reasoning.ended" }, { - "$ref": "#/components/schemas/session.next.reasoning.ended" + "$ref": "#/components/schemas/session.tool.input.started" }, { - "$ref": "#/components/schemas/session.next.retried" + "$ref": "#/components/schemas/session.tool.input.ended" }, { - "$ref": "#/components/schemas/session.next.compaction.started" + "$ref": "#/components/schemas/session.tool.called" }, { - "$ref": "#/components/schemas/session.next.compaction.ended" + "$ref": "#/components/schemas/session.tool.progress" }, { - "$ref": "#/components/schemas/session.next.revert.staged" + "$ref": "#/components/schemas/session.tool.success" }, { - "$ref": "#/components/schemas/session.next.revert.cleared" + "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.next.revert.committed" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" + }, + { + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" } ] }, @@ -15021,7 +16568,7 @@ "SessionLogItem": { "anyOf": [ { - "$ref": "#/components/schemas/SessionDurableEvent" + "$ref": "#/components/schemas/Session.Event.Durable" }, { "$ref": "#/components/schemas/EventLog.Synced" @@ -15041,17 +16588,9 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, - "watermark": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "cursor": { "type": "object", "properties": { @@ -15085,65 +16624,6 @@ ], "additionalProperties": false }, - "Model.Api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "package" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "settings" - ], - "additionalProperties": false - } - ] - }, "Model.Capabilities": { "type": "object", "properties": { @@ -15170,6 +16650,33 @@ ], "additionalProperties": false }, + "Model.Variant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "Money.USDPerMillionTokens": { + "type": "number" + }, "Model.Cost": { "type": "object", "properties": { @@ -15193,19 +16700,19 @@ "additionalProperties": false }, "input": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "output": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "write": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" } }, "required": [ @@ -15222,12 +16729,15 @@ ], "additionalProperties": false }, - "ModelV2.Info": { + "Model.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "modelID": { + "type": "string" + }, "providerID": { "type": "string" }, @@ -15237,66 +16747,28 @@ "name": { "type": "string" }, - "api": { - "$ref": "#/components/schemas/Model.Api" + "package": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" }, "capabilities": { "$ref": "#/components/schemas/Model.Capabilities" }, - "request": { - "type": "object", - "properties": { - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "settings", - "headers", - "body" - ], - "additionalProperties": false - }, "variants": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": [ - "id", - "settings", - "headers", - "body" - ], - "additionalProperties": false + "$ref": "#/components/schemas/Model.Variant" } }, "time": { @@ -15351,11 +16823,10 @@ }, "required": [ "id", + "modelID", "providerID", "name", - "api", "capabilities", - "request", "variants", "time", "cost", @@ -15386,63 +16857,6 @@ ], "additionalProperties": false }, - "Provider.AISDK": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "package" - ], - "additionalProperties": false - }, - "Provider.Native": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "settings" - ], - "additionalProperties": false - }, - "Provider.Api": { - "anyOf": [ - { - "$ref": "#/components/schemas/Provider.AISDK" - }, - { - "$ref": "#/components/schemas/Provider.Native" - } - ] - }, "ProviderV2.Info": { "type": "object", "properties": { @@ -15458,18 +16872,26 @@ "disabled": { "type": "boolean" }, - "api": { - "$ref": "#/components/schemas/Provider.Api" + "package": { + "type": "string" }, - "request": { - "$ref": "#/components/schemas/Provider.Request" + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" } }, "required": [ "id", "name", - "api", - "request" + "package" ], "additionalProperties": false }, @@ -16305,13 +17727,13 @@ ], "additionalProperties": false }, - "Mcp.Status.Disconnected": { + "Mcp.Status.Pending": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "disconnected" + "pending" ] } }, @@ -16400,7 +17822,7 @@ "$ref": "#/components/schemas/Mcp.Status.Connected" }, { - "$ref": "#/components/schemas/Mcp.Status.Disconnected" + "$ref": "#/components/schemas/Mcp.Status.Pending" }, { "$ref": "#/components/schemas/Mcp.Status.Disabled" @@ -16426,6 +17848,185 @@ ], "additionalProperties": false }, + "Mcp.Resource": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uri" + ], + "additionalProperties": false + }, + "Mcp.ResourceTemplate": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uriTemplate": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uriTemplate" + ], + "additionalProperties": false + }, + "Mcp.ResourceCatalog": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Resource" + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.ResourceTemplate" + } + } + }, + "required": [ + "resources", + "templates" + ], + "additionalProperties": false + }, + "Project.Vcs": { + "type": "string", + "enum": [ + "git", + "hg" + ] + }, + "Project.Icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Project.Commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "Project.Time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "initialized": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "worktree", + "time", + "sandboxes" + ], + "additionalProperties": false + }, "Project.Current": { "type": "object", "properties": { @@ -17538,7 +19139,7 @@ ], "additionalProperties": false }, - "CommandV2.Info": { + "Command.Info": { "type": "object", "properties": { "name": { @@ -17566,9 +19167,12 @@ ], "additionalProperties": false }, - "SkillV2.Info": { + "Skill.Info": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -17589,6 +19193,7 @@ } }, "required": [ + "id", "name", "location", "content" @@ -17606,6 +19211,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17615,36 +19223,6 @@ "models-dev.refreshed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17661,6 +19239,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17677,6 +19256,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17686,36 +19268,6 @@ "integration.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17732,6 +19284,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17748,6 +19301,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17757,36 +19313,6 @@ "integration.connection.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17805,6 +19331,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17821,6 +19348,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17830,36 +19360,6 @@ "catalog.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17876,6 +19376,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17892,6 +19393,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17901,36 +19405,6 @@ "agent.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17947,12 +19421,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "SnapshotFileDiff": { + "FileDiff.LegacyInfo": { "type": "object", "properties": { "file": { @@ -18016,7 +19491,7 @@ "$ref": "#/components/schemas/PermissionRule" } }, - "Session": { + "SessionV1.Info": { "type": "object", "properties": { "id": { @@ -18070,7 +19545,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -18258,6 +19733,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18282,11 +19760,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18312,7 +19788,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18324,7 +19800,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -18340,6 +19818,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18364,11 +19845,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18394,7 +19873,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18406,12 +19885,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.deleted": { + "session.deleted1": { "type": "object", "properties": { "id": { @@ -18422,6 +19903,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18446,11 +19930,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18476,7 +19958,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18488,7 +19970,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -18636,7 +20120,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -19277,6 +20761,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -19301,11 +20788,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19343,7 +20828,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -19359,6 +20846,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -19383,11 +20873,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19430,7 +20918,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -20824,6 +22314,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -20848,11 +22341,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20894,7 +22385,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -20910,6 +22403,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -20934,11 +22430,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20990,12 +22484,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.execution.settled": { + "session.usage.updated": { "type": "object", "properties": { "id": { @@ -21006,54 +22502,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.execution.settled" + "session.usage.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21062,34 +22528,30 @@ } ] }, - "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "interrupted" - ] + "cost": { + "$ref": "#/components/schemas/Money.USD" }, - "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ - "timestamp", "sessionID", - "outcome" + "cost", + "tokens" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.text.delta": { + "session.text.delta": { "type": "object", "properties": { "id": { @@ -21100,54 +22562,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.delta" + "session.text.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21164,18 +22596,22 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID", + "ordinal", "delta" ], "additionalProperties": false @@ -21183,12 +22619,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.reasoning.delta": { + "session.reasoning.delta": { "type": "object", "properties": { "id": { @@ -21199,54 +22636,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.reasoning.delta" + "session.reasoning.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21263,18 +22670,22 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "delta" ], "additionalProperties": false @@ -21282,12 +22693,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.tool.input.delta": { + "session.tool.input.delta": { "type": "object", "properties": { "id": { @@ -21298,54 +22710,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.delta" + "session.tool.input.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21370,7 +22752,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -21381,12 +22762,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.compaction.delta": { + "session.compaction.delta": { "type": "object", "properties": { "id": { @@ -21397,54 +22779,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.delta" + "session.compaction.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21453,22 +22805,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -21476,12 +22818,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "file.edited": { + "filesystem.changed": { "type": "object", "properties": { "id": { @@ -21492,45 +22835,18 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "file.edited" + "filesystem.changed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21539,16 +22855,26 @@ "properties": { "file": { "type": "string" + }, + "event": { + "type": "string", + "enum": [ + "add", + "change", + "unlink" + ] } }, "required": [ - "file" + "file", + "event" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", "data" ], @@ -21565,6 +22891,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21574,36 +22903,6 @@ "reference.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21620,6 +22919,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21636,6 +22936,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21645,36 +22948,6 @@ "permission.v2.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21730,6 +23003,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21746,6 +23020,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21755,36 +23032,6 @@ "permission.v2.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21821,6 +23068,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21837,6 +23085,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21846,36 +23097,6 @@ "plugin.added" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21894,6 +23115,52 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "plugin.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "plugin.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -21910,6 +23177,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21919,36 +23189,6 @@ "project.directories.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21967,6 +23207,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21983,6 +23224,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21992,36 +23236,6 @@ "command.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22038,6 +23252,52 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "config.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "config.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -22054,6 +23314,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22063,36 +23326,6 @@ "skill.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22109,88 +23342,7 @@ }, "required": [ "id", - "type", - "data" - ], - "additionalProperties": false - }, - "file.watcher.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "file.watcher.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": [ - "add", - "change", - "unlink" - ] - } - }, - "required": [ - "file", - "event" - ], - "additionalProperties": false - } - }, - "required": [ - "id", + "created", "type", "data" ], @@ -22268,6 +23420,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22277,36 +23432,6 @@ "pty.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22325,6 +23450,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22341,6 +23467,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22350,36 +23479,6 @@ "pty.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22398,6 +23497,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22414,6 +23514,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22423,36 +23526,6 @@ "pty.exited" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22485,6 +23558,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22501,6 +23575,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22510,36 +23587,6 @@ "pty.deleted" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22563,151 +23610,12 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "shell.created": { "type": "object", "properties": { @@ -22719,6 +23627,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22728,36 +23639,6 @@ "shell.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22776,6 +23657,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22792,6 +23674,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22801,36 +23686,6 @@ "shell.exited" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22889,6 +23744,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22905,6 +23761,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22914,36 +23773,6 @@ "shell.deleted" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22967,6 +23796,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23049,6 +23879,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23058,36 +23891,6 @@ "question.v2.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23131,6 +23934,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23153,6 +23957,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23162,36 +23969,6 @@ "question.v2.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23231,6 +24008,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23247,6 +24025,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23256,36 +24037,6 @@ "question.v2.rejected" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23318,6 +24069,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23886,6 +24638,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23895,36 +24650,6 @@ "form.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23950,6 +24675,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24013,6 +24739,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24022,36 +24751,6 @@ "form.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24083,6 +24782,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24099,6 +24799,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24108,36 +24811,6 @@ "form.cancelled" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24165,6 +24838,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24204,6 +24878,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24213,36 +24890,6 @@ "todo.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24273,6 +24920,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24391,6 +25039,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24400,36 +25051,6 @@ "session.status" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24457,6 +25078,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24473,6 +25095,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24482,36 +25107,6 @@ "session.idle" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24535,6 +25130,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24551,6 +25147,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24560,36 +25159,6 @@ "tui.prompt.append" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24608,6 +25177,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24624,6 +25194,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24633,36 +25206,6 @@ "tui.command.execute" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24707,6 +25250,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24723,6 +25267,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24732,36 +25279,6 @@ "tui.toast.show" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24808,6 +25325,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24824,6 +25342,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24833,36 +25354,6 @@ "tui.session.select" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24887,6 +25378,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24903,6 +25395,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24912,36 +25407,6 @@ "installation.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24960,6 +25425,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24976,6 +25442,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24985,36 +25454,6 @@ "installation.update-available" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25033,6 +25472,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25049,6 +25489,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25058,36 +25501,6 @@ "vcs.branch.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25103,6 +25516,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25119,6 +25533,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25128,36 +25545,6 @@ "mcp.status.changed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25176,6 +25563,54 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "mcp.resources.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.resources.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -25192,6 +25627,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25201,36 +25639,6 @@ "permission.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25308,6 +25716,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25324,6 +25733,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25333,36 +25745,6 @@ "permission.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25404,6 +25786,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25507,6 +25890,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25516,36 +25902,6 @@ "question.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25596,6 +25952,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25618,6 +25975,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25627,36 +25987,6 @@ "question.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25696,6 +26026,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25712,6 +26043,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25721,36 +26055,6 @@ "question.rejected" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25783,6 +26087,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25799,6 +26104,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25808,36 +26116,6 @@ "session.error" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25900,6 +26178,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25926,43 +26205,6 @@ } ] }, - "durable": { - "anyOf": [ - { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, "location": { "anyOf": [ { @@ -26021,7 +26263,7 @@ "$ref": "#/components/schemas/session.updated" }, { - "$ref": "#/components/schemas/session.deleted" + "$ref": "#/components/schemas/session.deleted1" }, { "$ref": "#/components/schemas/message.updated" @@ -26036,115 +26278,136 @@ "$ref": "#/components/schemas/message.part.removed" }, { - "$ref": "#/components/schemas/session.next.agent.switched" + "$ref": "#/components/schemas/session.agent.selected" }, { - "$ref": "#/components/schemas/session.next.model.switched" + "$ref": "#/components/schemas/session.model.selected" }, { - "$ref": "#/components/schemas/session.next.moved" + "$ref": "#/components/schemas/session.moved" }, { - "$ref": "#/components/schemas/session.next.renamed" + "$ref": "#/components/schemas/session.renamed" }, { - "$ref": "#/components/schemas/session.next.forked" + "$ref": "#/components/schemas/session.usage.updated" }, { - "$ref": "#/components/schemas/session.next.prompted" + "$ref": "#/components/schemas/session.deleted" }, { - "$ref": "#/components/schemas/session.next.prompt.admitted" + "$ref": "#/components/schemas/session.forked" }, { - "$ref": "#/components/schemas/session.next.execution.settled" + "$ref": "#/components/schemas/session.prompt.promoted" }, { - "$ref": "#/components/schemas/session.next.instructions.updated" + "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.next.synthetic" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.next.skill.activated" + "$ref": "#/components/schemas/session.execution.succeeded" }, { - "$ref": "#/components/schemas/session.next.shell.started" + "$ref": "#/components/schemas/session.execution.failed" }, { - "$ref": "#/components/schemas/session.next.shell.ended" + "$ref": "#/components/schemas/session.execution.interrupted" }, { - "$ref": "#/components/schemas/session.next.step.started" + "$ref": "#/components/schemas/session.instructions.updated" }, { - "$ref": "#/components/schemas/session.next.step.ended" + "$ref": "#/components/schemas/session.synthetic" }, { - "$ref": "#/components/schemas/session.next.step.failed" + "$ref": "#/components/schemas/session.skill.activated" }, { - "$ref": "#/components/schemas/session.next.text.started" + "$ref": "#/components/schemas/session.shell.started" }, { - "$ref": "#/components/schemas/session.next.text.delta" + "$ref": "#/components/schemas/session.shell.ended" }, { - "$ref": "#/components/schemas/session.next.text.ended" + "$ref": "#/components/schemas/session.step.started" }, { - "$ref": "#/components/schemas/session.next.reasoning.started" + "$ref": "#/components/schemas/session.step.ended" }, { - "$ref": "#/components/schemas/session.next.reasoning.delta" + "$ref": "#/components/schemas/session.step.failed" }, { - "$ref": "#/components/schemas/session.next.reasoning.ended" + "$ref": "#/components/schemas/session.text.started" }, { - "$ref": "#/components/schemas/session.next.tool.input.started" + "$ref": "#/components/schemas/session.text.delta" }, { - "$ref": "#/components/schemas/session.next.tool.input.delta" + "$ref": "#/components/schemas/session.text.ended" }, { - "$ref": "#/components/schemas/session.next.tool.input.ended" + "$ref": "#/components/schemas/session.reasoning.started" }, { - "$ref": "#/components/schemas/session.next.tool.called" + "$ref": "#/components/schemas/session.reasoning.delta" }, { - "$ref": "#/components/schemas/session.next.tool.progress" + "$ref": "#/components/schemas/session.reasoning.ended" }, { - "$ref": "#/components/schemas/session.next.tool.success" + "$ref": "#/components/schemas/session.tool.input.started" }, { - "$ref": "#/components/schemas/session.next.tool.failed" + "$ref": "#/components/schemas/session.tool.input.delta" }, { - "$ref": "#/components/schemas/session.next.retried" + "$ref": "#/components/schemas/session.tool.input.ended" }, { - "$ref": "#/components/schemas/session.next.compaction.started" + "$ref": "#/components/schemas/session.tool.called" }, { - "$ref": "#/components/schemas/session.next.compaction.delta" + "$ref": "#/components/schemas/session.tool.progress" }, { - "$ref": "#/components/schemas/session.next.compaction.ended" + "$ref": "#/components/schemas/session.tool.success" }, { - "$ref": "#/components/schemas/session.next.revert.staged" + "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.next.revert.cleared" + "$ref": "#/components/schemas/session.retry.scheduled" }, { - "$ref": "#/components/schemas/session.next.revert.committed" + "$ref": "#/components/schemas/session.compaction.admitted" }, { - "$ref": "#/components/schemas/file.edited" + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.delta" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" + }, + { + "$ref": "#/components/schemas/filesystem.changed" }, { "$ref": "#/components/schemas/reference.updated" @@ -26158,6 +26421,9 @@ { "$ref": "#/components/schemas/plugin.added" }, + { + "$ref": "#/components/schemas/plugin.updated" + }, { "$ref": "#/components/schemas/project.directories.updated" }, @@ -26165,10 +26431,10 @@ "$ref": "#/components/schemas/command.updated" }, { - "$ref": "#/components/schemas/skill.updated" + "$ref": "#/components/schemas/config.updated" }, { - "$ref": "#/components/schemas/file.watcher.updated" + "$ref": "#/components/schemas/skill.updated" }, { "$ref": "#/components/schemas/pty.created" @@ -26242,6 +26508,9 @@ { "$ref": "#/components/schemas/mcp.status.changed" }, + { + "$ref": "#/components/schemas/mcp.resources.changed" + }, { "$ref": "#/components/schemas/permission.asked" }, @@ -26272,68 +26541,6 @@ }, "contentMediaType": "application/json" }, - "EventLog.Hint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.hint" - ] - }, - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "type", - "aggregateID", - "seq" - ], - "additionalProperties": false, - "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." - }, - "EventLog.SweepRequired": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.sweep_required" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false, - "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." - }, - "EventLog.Change": { - "anyOf": [ - { - "$ref": "#/components/schemas/EventLog.Hint" - }, - { - "$ref": "#/components/schemas/EventLog.SweepRequired" - } - ] - }, - "EventLog.ChangeStream": { - "type": "string", - "contentSchema": { - "$ref": "#/components/schemas/EventLog.Change" - }, - "contentMediaType": "application/json" - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -26863,28 +27070,28 @@ "security": [], "tags": [ { - "name": "server.health" + "name": "health" }, { - "name": "server.location" + "name": "location" }, { - "name": "server.agent" + "name": "agent" }, { - "name": "plugins", + "name": "plugin", "description": "Experimental plugin routes." }, { - "name": "sessions", + "name": "session", "description": "Experimental session routes." }, { - "name": "messages", + "name": "session", "description": "Experimental message routes." }, { - "name": "models", + "name": "model", "description": "Experimental model routes." }, { @@ -26892,30 +27099,30 @@ "description": "Experimental one-shot generation routes." }, { - "name": "providers", + "name": "provider", "description": "Experimental provider routes." }, { - "name": "integrations", + "name": "integration", "description": "Integration discovery and authentication routes." }, { "name": "mcp", - "description": "MCP server status routes." + "description": "MCP server and resource routes." }, { - "name": "server.credential" + "name": "credential" }, { - "name": "projects", + "name": "project", "description": "Location-scoped project routes." }, { - "name": "forms", + "name": "form", "description": "Session form routes." }, { - "name": "permissions", + "name": "permission", "description": "Experimental permission routes." }, { @@ -26923,15 +27130,15 @@ "description": "Experimental location-scoped filesystem routes." }, { - "name": "commands", + "name": "command", "description": "Experimental command routes." }, { - "name": "skills", + "name": "skill", "description": "Experimental skill routes." }, { - "name": "events", + "name": "event", "description": "Experimental event stream routes." }, { @@ -26943,7 +27150,7 @@ "description": "Experimental location-scoped shell command routes." }, { - "name": "session questions", + "name": "question", "description": "Experimental session question routes." }, { @@ -26957,6 +27164,9 @@ { "name": "vcs", "description": "Location-scoped version control routes." + }, + { + "name": "debug" } ] } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index db799e5894..608019da46 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => { const spec = await opencodeSpec() const result = OpenAPI.fromSpec({ spec, baseUrl }) - expect(result.skipped).toHaveLength(5) + expect(result.skipped).toHaveLength(4) expect(result.skipped).toContainEqual({ method: "GET", path: "/api/pty/{ptyID}/connect", reason: "WebSocket operations are not supported", }) - expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3) + expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2) expect(result.skipped).toContainEqual({ method: "GET", path: "/api/fs/read/*", @@ -210,11 +210,11 @@ describe("OpenAPI.fromSpec", () => { if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }") expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined() - expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() + expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false) expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() - expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined() }) test("preserves operation path sanitization and collision handling", () => { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 1831a7222d..5593831127 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -464,6 +464,54 @@ describe("H5: builtin coercion functions work as array callbacks", () => { }) }) +describe("for...of assignment destructuring", () => { + test("assigns entry pairs into predeclared variables", async () => { + expect( + await value(` + let key + let item + const out = [] + for ([key, item] of Object.entries({ a: 1, b: 2 })) out.push(key + item) + return { key, item, out } + `), + ).toEqual({ key: "b", item: 2, out: ["a1", "b2"] }) + }) + + test("assigns object patterns and defaults", async () => { + expect( + await value(` + let id + let label + const labels = [] + for ({ id, label = "unknown" } of [{ id: 1 }, { id: 2, label: "two" }]) labels.push(label) + return { id, label, labels } + `), + ).toEqual({ id: 2, label: "two", labels: ["unknown", "two"] }) + }) +}) + +describe("sequence expressions", () => { + test("evaluate left to right and return the final value", async () => { + expect(await value(`let x = 0; const result = (x += 1, x *= 3, x + 2); return { x, result }`)).toEqual({ + x: 3, + result: 5, + }) + }) + + test("support comma-separated for-loop updates", async () => { + expect( + await value(` + const pairs = [] + for (let left = 0, right = 3; left < right; left++, right--) pairs.push([left, right]) + return pairs + `), + ).toEqual([ + [0, 3], + [1, 2], + ]) + }) +}) + describe("destructuring assignment", () => { test("assigns object and array patterns to existing bindings", async () => { expect( diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 545d463abf..ce5f1e03a4 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -48,6 +48,14 @@ const failingTool = Tool.make({ run: () => Effect.fail(toolError("Lookup refused")), }) +const completedTool = (trace: Trace) => + Tool.make({ + description: "Return the number of completed sleepy calls", + input: Schema.Struct({}), + output: Schema.Number, + run: () => Effect.succeed(trace.completed), + }) + const run = ( code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}, @@ -55,7 +63,7 @@ const run = ( const trace = options.trace ?? makeTrace() return Effect.runPromise( CodeMode.execute({ - tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, + tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } }, code, ...(options.limits ? { limits: options.limits } : {}), }), @@ -75,6 +83,42 @@ const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.E } describe("first-class promise values", () => { + test("async functions return promises with isolated concurrent invocations", async () => { + expect( + await value(` + const load = async (id) => { + const result = await tools.host.sleepy({ id, ms: 20 }) + return [id, result] + } + const first = load(1) + const second = load(2) + return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])] + `), + ).toEqual([ + true, + true, + [ + [1, 1], + [2, 2], + ], + ]) + }) + + test("async function errors reject instead of throwing at the call site", async () => { + expect( + await value(` + const fail = async () => { throw new Error("boom") } + const promise = fail() + try { + await promise + return "no" + } catch (error) { + return error.message + } + `), + ).toBe("boom") + }) + test("an un-awaited tool call starts eagerly, in call order, before any await", async () => { const trace = makeTrace() const result = await value( @@ -163,9 +207,33 @@ describe("first-class promise values", () => { return "done" `) expect(diagnostic.kind).toBe("ToolFailure") - expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") + expect(diagnostic.message).toContain("Lookup refused") + expect(diagnostic.suggestions?.join(" ")).toContain("Await promises") + }) + + test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => { + const diagnostic = await error(` + const fail = async () => { throw new Error("boom") } + fail() + return "done" + `) + expect(diagnostic.kind).toBe("ExecutionFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") + expect(diagnostic.message).toContain("boom") + }) + + test("drains promises started by an async function after an await", async () => { + const diagnostic = await error(` + const run = async () => { + await tools.host.sleepy({ id: 1 }) + tools.host.fail({}) + } + run() + return "done" + `) + expect(diagnostic.kind).toBe("ToolFailure") expect(diagnostic.message).toContain("Lookup refused") - expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)") }) }) @@ -177,6 +245,12 @@ describe("promises at data boundaries", () => { expect(diagnostic.message).toContain("await tools.ns.tool(...)") }) + test("collection helpers do not let un-awaited promises cross the result boundary", async () => { + const diagnostic = await error(`return Array.from([Promise.resolve(1)])`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) expect(diagnostic.kind).toBe("InvalidDataValue") @@ -232,6 +306,19 @@ describe("Promise.all over arbitrary arrays", () => { expect(trace.maxActive).toBeGreaterThan(1) }) + test("runs async map callbacks concurrently", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [1, 2, 3, 4] + return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 }))) + `, + { trace }, + ) + expect(result).toEqual([1, 2, 3, 4]) + expect(trace.maxActive).toBeGreaterThan(1) + }) + test("caps live tool-call concurrency at the fixed internal constant (8)", async () => { const trace = makeTrace() const result = await value( @@ -265,6 +352,28 @@ describe("Promise.all over arbitrary arrays", () => { ).toBe("Lookup refused") }) + test("rejects before an earlier slow promise fulfills", async () => { + const trace = makeTrace() + expect( + await value( + ` + try { + await Promise.all([ + tools.host.sleepy({ id: 1, ms: 100 }), + tools.host.fail({}), + ]) + return -1 + } catch { + return await tools.host.completed({}) + } + `, + { trace }, + ), + ).toBe(0) + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + test("a non-collection argument is a clear error", async () => { const diagnostic = await error(`return await Promise.all(42)`) expect(diagnostic.message).toContain("Promise.all expects an array") diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 489de451a7..d5870dab8d 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -19,6 +19,28 @@ const error = async (code: string) => { return result.error } +describe("Number and Math", () => { + test("Math.random returns a number in [0, 1)", async () => { + expect(await value(`const n = Math.random(); return typeof n === "number" && n >= 0 && n < 1`)).toBe(true) + }) + + test("Number exposes native non-finite constants", async () => { + expect( + await value( + `return [Number.isNaN(Number.NaN), Number.POSITIVE_INFINITY === Infinity, Number.NEGATIVE_INFINITY === -Infinity]`, + ), + ).toEqual([true, true, true]) + }) + + test("Number valueOf returns its primitive receiver", async () => { + expect(await value(`return (42).valueOf()`)).toBe(42) + }) + + test("Number valueOf does not enable boxed numbers", async () => { + expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax") + }) +}) + describe("Date", () => { test("Date.now() returns a number", async () => { expect(await value(`return typeof Date.now()`)).toBe("number") @@ -586,6 +608,85 @@ describe("Set", () => { }) describe("stdlib integration", () => { + test("Object values and entries accept arrays", async () => { + expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([ + ["a", "b"], + [ + ["0", "a"], + ["1", "b"], + ], + ]) + expect(await value(`const match = /a/.exec("ba"); return [Object.values(match), Object.entries(match)]`)).toEqual([ + ["a", 1], + [ + ["0", "a"], + ["index", 1], + ], + ]) + expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual(["0", "index"]) + }) + + test("Object.fromEntries accepts every supported entry collection", async () => { + expect( + await value(` + return [ + Object.fromEntries([["a", 1]]), + Object.fromEntries(new Map([["b", 2]])), + Object.fromEntries(new Set([["c", 3]])), + Object.fromEntries(new URLSearchParams("d=4")), + Object.fromEntries([{ 0: "e", 1: 5 }]), + Object.fromEntries(new Set([[{}, 6], [new Date(0), 7], [null, 8], [undefined, 9]])), + ] + `), + ).toEqual([ + { a: 1 }, + { b: 2 }, + { c: 3 }, + { d: "4" }, + { e: 5 }, + { "[object Object]": 6, "1970-01-01T00:00:00.000Z": 7, null: 8, undefined: 9 }, + ]) + expect(await value(`try { Object.fromEntries(new Set([Math.max])); return false } catch { return true }`)).toBe( + true, + ) + expect( + await value( + `try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`, + ), + ).toBe(true) + }) + + test("deterministic Math methods match the host runtime", async () => { + const result = await value(` + return [ + Math.acos(0.5), Math.acosh(2), Math.asin(0.5), Math.asinh(2), Math.atan(1), Math.atan2(1, 2), Math.atanh(0.5), + Math.cos(0.5), Math.cosh(0.5), Math.sin(0.5), Math.sinh(0.5), Math.tan(0.5), Math.tanh(0.5), + Math.log1p(0.5), Math.expm1(0.5), Math.f16round(1.337), Math.fround(1.337), Math.clz32(1), Math.imul(2, 3), + ] + `) + expect(result).toEqual([ + Math.acos(0.5), + Math.acosh(2), + Math.asin(0.5), + Math.asinh(2), + Math.atan(1), + Math.atan2(1, 2), + Math.atanh(0.5), + Math.cos(0.5), + Math.cosh(0.5), + Math.sin(0.5), + Math.sinh(0.5), + Math.tan(0.5), + Math.tanh(0.5), + Math.log1p(0.5), + Math.expm1(0.5), + Math.f16round(1.337), + Math.fround(1.337), + Math.clz32(1), + Math.imul(2, 3), + ]) + }) + test("Object.assign mutates and returns its target", async () => { expect( await value(` @@ -672,6 +773,43 @@ describe("sandbox values at intra-sandbox checkpoints", () => { ) }) + test("Object.values/entries preserve nested object identity", async () => { + expect( + await value(` + const child = { selected: false } + const rows = { a: child } + Object.values(rows)[0].selected = true + return child.selected + `), + ).toBe(true) + expect( + await value(` + const child = { selected: false } + const rows = { a: child } + Object.entries(rows)[0][1].selected = true + return child.selected + `), + ).toBe(true) + }) + + test("Object enumeration preserves promises and callable references", async () => { + expect( + await value(` + const pending = Promise.resolve(1) + const source = { pending } + return [Object.keys(source), Object.hasOwn(source, "pending"), await Object.values(source)[0], await Object.entries(source)[0][1]] + `), + ).toEqual([["pending"], true, 1, 1]) + expect(await value(`return Object.values({ max: Math.max })[0](1, 2)`)).toBe(2) + }) + + test("Object enumeration rejects invalid receivers and gives promises an await hint", async () => { + const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("await") + expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue") + }) + test("Object.assign keeps Maps usable", async () => { expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( 1, @@ -694,6 +832,53 @@ describe("sandbox values at intra-sandbox checkpoints", () => { expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) }) + test("Array.from and Array.of preserve nested object identity", async () => { + expect( + await value(` + const child = { selected: false } + Array.from([child])[0].selected = true + return child.selected + `), + ).toBe(true) + expect( + await value(` + const child = { selected: false } + Array.of(child)[0].selected = true + return child.selected + `), + ).toBe(true) + }) + + test("Array.from and Array.of preserve promises and callable references", async () => { + expect( + await value(` + const pending = Promise.resolve(1) + return [await Array.from([pending])[0], await Array.of(pending)[0]] + `), + ).toEqual([1, 1]) + expect(await value(`return [Array.from([Math.max])[0](1, 2), Array.of(Math.max)[0](3, 4)]`)).toEqual([2, 4]) + }) + + test("Array.from preserves identity across supported collection shapes", async () => { + expect( + await value(` + const child = { selected: false } + const fromArrayLike = Array.from({ 0: child, length: 1 }) + const fromMap = Array.from(new Map([["child", child]])) + const fromSet = Array.from(new Set([child])) + fromArrayLike[0].selected = true + return [fromMap[0][1] === child, fromSet[0] === child, child.selected] + `), + ).toEqual([true, true, true]) + }) + + test("Array.from rejects invalid receivers and gives promises an await hint", async () => { + const diagnostic = await error(`return Array.from(Promise.resolve([1]))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("await") + expect((await error(`return Array.from(() => 1)`)).kind).toBe("InvalidDataValue") + }) + test("regexes stay callable through Object.values", async () => { expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) }) diff --git a/packages/core/schema.json b/packages/core/schema.json index 222cc7b399..d419909444 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,10 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "992b24b9-f3e9-41f5-87a5-4917d1423169", - "prevIds": [ - "96e9fe64-660f-4a73-9414-b38bb7eac290" - ], + "id": "b0355fd9-bf41-42e3-9dca-76107de27ecd", + "prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"], "ddl": [ { "name": "workspace", @@ -1012,13 +1010,23 @@ "autoincrement": false, "default": null, "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, "name": "prompt", "entityType": "columns", "table": "session_input" }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, @@ -1166,6 +1174,26 @@ "entityType": "columns", "table": "session" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fork_session_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fork_message_id", + "entityType": "columns", + "table": "session" + }, { "type": "text", "notNull": true, @@ -1547,13 +1575,9 @@ "table": "session_share" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1562,13 +1586,9 @@ "table": "workspace" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1577,13 +1597,9 @@ "table": "account_state" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1592,13 +1608,9 @@ "table": "event" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1607,13 +1619,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1622,13 +1630,9 @@ "table": "project_directory" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1637,13 +1641,9 @@ "table": "instruction_checkpoint" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1652,13 +1652,9 @@ "table": "instruction_entry" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1667,13 +1663,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1682,13 +1674,9 @@ "table": "part" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1697,13 +1685,9 @@ "table": "session_input" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1712,13 +1696,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1727,13 +1707,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1742,13 +1718,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1757,184 +1729,140 @@ "table": "session_share" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "project_id", - "directory" - ], + "columns": ["project_id", "directory"], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": [ - "session_id", - "key" - ], + "columns": ["session_id", "key"], "nameExplicit": false, "name": "instruction_entry_pk", "entityType": "pks", "table": "instruction_entry" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "instruction_checkpoint_pk", "table": "instruction_checkpoint", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", @@ -2066,6 +1994,10 @@ "value": "promoted_seq", "isExpression": false }, + { + "value": "type", + "isExpression": false + }, { "value": "delivery", "isExpression": false @@ -2078,7 +2010,21 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_input_session_pending_delivery_seq_idx", + "name": "session_input_session_pending_type_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null", + "origin": "manual", + "name": "session_input_session_pending_compaction_idx", "entityType": "indexes", "table": "session_input" }, diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 2dd54123ed..8ba976399d 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -8,6 +8,8 @@ import { State } from "./state" export const ID = Agent.ID export type ID = typeof ID.Type +export const Name = Agent.Name +export type Name = Agent.Name export const defaultID = ID.make("build") export const Color = Agent.Color diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index f7474b244a..3330c77f4a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -6,6 +6,7 @@ import type { JSONValue, LanguageModelV3, LanguageModelV3CallOptions, + LanguageModelV3FinishReason, LanguageModelV3FunctionTool, LanguageModelV3Message, LanguageModelV3Prompt, @@ -304,6 +305,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { const route: AnyRoute = { id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, provider: ProviderID.make(info.providerID), + providerMetadataKey: optionKey, protocol: "ai-sdk", endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), auth: Auth.none, @@ -416,7 +418,7 @@ function assistantPart(part: ContentPart): AssistantContent { case "media": return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] case "reasoning": - return [{ type: "reasoning", text: part.text }] + return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }] case "tool-call": return [ { @@ -425,6 +427,7 @@ function assistantPart(part: ContentPart): AssistantContent { toolName: part.name, input: part.input, providerExecuted: part.providerExecuted, + providerOptions: providerOptions(part.providerMetadata), }, ] case "tool-result": @@ -440,6 +443,7 @@ function toolResultPart(part: ContentPart): ToolResultContent[] { toolCallId: part.id, toolName: part.name, output: toolOutput(part.result), + providerOptions: providerOptions(part.providerMetadata), }, ] } @@ -624,8 +628,8 @@ function usage(input: Extract["us return Object.values(output).some((value) => value !== undefined) ? output : undefined } -function finishReason(value: unknown): FinishReason { - return Schema.is(FinishReason)(value) ? value : "unknown" +function finishReason(value: LanguageModelV3FinishReason): FinishReason { + return value.unified === "other" ? "unknown" : value.unified } function providerMetadata(value: unknown) { diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index 53e23d6013..9e86fbbe82 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -7,8 +7,11 @@ export class Timeout extends Schema.Class("ConfigV2.MCP.Timeout")({ startup: PositiveInt.pipe(Schema.optional).annotate({ description: "Maximum time in milliseconds to establish and initialize the MCP server.", }), - request: PositiveInt.pipe(Schema.optional).annotate({ - description: "Maximum time in milliseconds to wait for MCP catalog/list requests after initialization.", + catalog: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.", + }), + execution: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.", }), }) {} diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 3cfc799d72..1bb05e0fa1 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProviderPlugin from "./provider" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" @@ -91,8 +92,8 @@ export const Plugin = define({ input: cost.input, output: cost.output, cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, + read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, + write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, }, })) } diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index ec37182395..e2fa972806 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProvider from "./provider" import { Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" import { ModelV2 } from "../model" const JsonRecord = Schema.Record(Schema.String, Schema.Json) @@ -17,8 +18,8 @@ export class Request extends Schema.Class("ConfigV2.Provider.Request")( }) {} class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ - read: Schema.Finite.pipe(Schema.optional), - write: Schema.Finite.pipe(Schema.optional), + read: Money.USDPerMillionTokens.pipe(Schema.optional), + write: Money.USDPerMillionTokens.pipe(Schema.optional), }) {} class Cost extends Schema.Class("ConfigV2.Model.Cost")({ @@ -26,8 +27,8 @@ class Cost extends Schema.Class("ConfigV2.Model.Cost")({ type: Schema.Literal("context"), size: Schema.Int, }).pipe(Schema.optional), - input: Schema.Finite, - output: Schema.Finite, + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, cache: Cache.pipe(Schema.optional), }) {} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 9d3e4e673f..5b1735573d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -44,6 +44,10 @@ export const migrations = ( import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260703181610_event_created_column"), import("./migration/20260703190000_reset_v2_shell_event_payloads"), + import("./migration/20260703200000_reset_v2_session_events"), import("./migration/20260705180000_rename_instructions"), + import("./migration/20260706223930_add-session-fork"), + import("./migration/20260707010146_durable_session_inbox"), + import("./migration/20260707120000_migrate_prelaunch_v2_state"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts b/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts new file mode 100644 index 0000000000..75108b3f11 --- /dev/null +++ b/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703200000_reset_v2_session_events", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260706223930_add-session-fork.ts b/packages/core/src/database/migration/20260706223930_add-session-fork.ts new file mode 100644 index 0000000000..2d7b09b34a --- /dev/null +++ b/packages/core/src/database/migration/20260706223930_add-session-fork.ts @@ -0,0 +1,39 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260706223930_add-session-fork", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`) + yield* tx.run(` + UPDATE \`session\` + SET + \`parent_id\` = NULL, + \`fork_session_id\` = ( + SELECT json_extract(\`event\`.\`data\`, '$.parentID') + FROM \`event\` + WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\` + AND \`event\`.\`type\` = 'session.forked' + ORDER BY \`event\`.\`seq\` + LIMIT 1 + ), + \`fork_message_id\` = ( + SELECT json_extract(\`event\`.\`data\`, '$.from') + FROM \`event\` + WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\` + AND \`event\`.\`type\` = 'session.forked' + ORDER BY \`event\`.\`seq\` + LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 + FROM \`event\` + WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\` + AND \`event\`.\`type\` = 'session.forked' + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260707010146_durable_session_inbox.ts b/packages/core/src/database/migration/20260707010146_durable_session_inbox.ts new file mode 100644 index 0000000000..e490992620 --- /dev/null +++ b/packages/core/src/database/migration/20260707010146_durable_session_inbox.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260707010146_durable_session_inbox", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_session_input\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`prompt\` text, + \`delivery\` text, + \`admitted_seq\` integer NOT NULL, + \`promoted_seq\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run( + `INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`, + ) + yield* tx.run(`DROP TABLE \`session_input\`;`) + yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`) + yield* tx.run( + `CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts new file mode 100644 index 0000000000..e921d40904 --- /dev/null +++ b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts @@ -0,0 +1,227 @@ +import { sql } from "drizzle-orm" +import { Effect, Schema } from "effect" +import type { DatabaseMigration } from "../migration" + +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString) +const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)) + +export default { + id: "20260707120000_migrate_prelaunch_v2_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run( + sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`, + ) + const messages = yield* tx.all<{ id: string; type: string; data: string }>( + sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`, + ) + for (const row of messages) { + const data = object(decodeJson(row.data)) + yield* tx.run( + sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`, + ) + } + + yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`) + const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql` + SELECT id, aggregate_id as aggregateID, seq, type, data + FROM event + WHERE type IN ( + 'session.skill.activated.1', + 'session.skill.activated.2', + 'session.compaction.started.1', + 'session.compaction.started.2', + 'session.compaction.ended.1', + 'session.compaction.failed.1', + 'session.compaction.failed.2', + 'session.revert.staged.1', + 'session.revert.staged.2' + ) + ORDER BY aggregate_id, seq + `) + const compactionReasons = new Map() + for (const row of events) { + const data = object(decodeJson(row.data)) + if (row.type.startsWith("session.compaction.ended.")) { + compactionReasons.delete(row.aggregateID) + continue + } + const event = eventData(row.type, data, compactionReasons.get(row.aggregateID)) + if (row.type.startsWith("session.compaction.started.")) + compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual") + if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID) + yield* tx.run( + sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`, + ) + } + }) + }, +} satisfies DatabaseMigration.Migration + +function messageData(type: string, data: Record) { + if (type === "skill") + return defined({ + metadata: data.metadata, + time: data.time, + skill: data.skill ?? data.id ?? data.name, + name: data.name, + text: data.text, + }) + if (type === "shell") { + const shell = object(data.shell) + return defined({ + metadata: data.metadata, + time: data.time, + shellID: data.shellID ?? shell.id, + command: data.command ?? shell.command, + status: data.status ?? shell.status, + exit: data.exit ?? shell.exit, + output: data.output, + }) + } + if (type === "assistant") + return defined({ + metadata: data.metadata, + time: data.time, + agent: data.agent, + model: data.model, + content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content, + snapshot: data.snapshot, + finish: data.finish, + cost: data.cost, + tokens: data.tokens, + error: data.error, + retry: data.retry, + }) + if (type === "compaction") { + if (data.status === "failed") + return defined({ + metadata: data.metadata, + time: data.time, + status: data.status, + reason: data.reason, + error: data.error ?? genericCompactionError, + }) + return defined({ + metadata: data.metadata, + time: data.time, + status: data.status, + reason: data.reason, + summary: data.summary, + recent: data.recent, + }) + } + if (type === "synthetic") + return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description }) + const { sessionID: _, ...current } = data + return current +} + +function assistantContent(value: unknown) { + const content = object(value) + if (content.type === "text") return defined({ type: content.type, text: content.text }) + if (content.type === "reasoning") + return defined({ type: content.type, text: content.text, state: content.state, time: content.time }) + if (content.type !== "tool") return content + return defined({ + type: content.type, + id: content.id, + name: content.name, + executed: content.executed, + providerState: content.providerState, + providerResultState: content.providerResultState, + state: toolState(content.state), + time: content.time, + }) +} + +function toolState(value: unknown) { + const state = object(value) + if (state.status === "pending" || state.status === "streaming") + return defined({ status: "streaming", input: state.input }) + if (state.status === "running") + return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content }) + if (state.status === "completed") + return defined({ + status: state.status, + input: state.input, + structured: state.structured, + content: state.content, + result: state.result, + }) + if (state.status === "error") + return defined({ + status: state.status, + input: state.input, + structured: state.structured, + content: state.content, + error: state.error, + result: state.result, + }) + return state +} + +function eventData(type: string, data: Record, compactionReason?: "auto" | "manual") { + if (type.startsWith("session.skill.activated.")) + return { + type: "session.skill.activated.1", + data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }), + } + if (type.startsWith("session.compaction.started.")) + return { + type: "session.compaction.started.1", + data: defined({ + sessionID: data.sessionID, + reason: data.reason, + recent: data.recent ?? "", + inputID: data.inputID, + }), + } + if (type.startsWith("session.compaction.failed.")) + return { + type: "session.compaction.failed.1", + data: defined({ + sessionID: data.sessionID, + reason: data.reason ?? compactionReason ?? "manual", + error: data.error ?? genericCompactionError, + inputID: data.inputID, + }), + } + const revert = object(data.revert) + return { + type: "session.revert.staged.1", + data: defined({ + sessionID: data.sessionID, + revert: defined({ + messageID: revert.messageID, + partID: revert.partID, + snapshot: revert.snapshot, + files: Array.isArray(revert.files) + ? revert.files.map((value) => { + const file = object(value) + return defined({ + file: file.file ?? file.path, + patch: file.patch, + additions: file.additions, + deletions: file.deletions, + status: file.status, + }) + }) + : undefined, + }), + }), + } +} + +const genericCompactionError = { + type: "compaction.failed", + message: "Compaction failed before recording an error", +} + +function object(value: unknown): Record { + return isObject(value) ? value : {} +} + +function defined(value: Record) { + return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) +} diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index dfa24f9b94..0bb6050bf6 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -170,8 +170,9 @@ export default { CREATE TABLE \`session_input\` ( \`id\` text PRIMARY KEY, \`session_id\` text NOT NULL, - \`prompt\` text NOT NULL, - \`delivery\` text NOT NULL, + \`type\` text NOT NULL, + \`prompt\` text, + \`delivery\` text, \`admitted_seq\` integer NOT NULL, \`promoted_seq\` integer, \`time_created\` integer NOT NULL, @@ -196,6 +197,8 @@ export default { \`project_id\` text NOT NULL, \`workspace_id\` text, \`parent_id\` text, + \`fork_session_id\` text, + \`fork_message_id\` text, \`slug\` text NOT NULL, \`directory\` text NOT NULL, \`path\` text, @@ -259,7 +262,10 @@ export default { yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) yield* tx.run( - `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, + `CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`, ) yield* tx.run( `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index baac20d200..7981bb26de 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no // TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Add snapshots / undo after V2 snapshot design exists. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists. -// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits. +// TODO: Design multi-file transactions / rollback if patch needs atomic edits. // Until then, edits are sequential and report partial application. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement. diff --git a/packages/core/src/file.ts b/packages/core/src/file.ts index 87745c01ee..0f48d9a04c 100644 --- a/packages/core/src/file.ts +++ b/packages/core/src/file.ts @@ -1,6 +1,6 @@ export * as File from "./file" -import { Revert } from "@opencode-ai/schema/revert" +import { FileDiff } from "@opencode-ai/schema/file-diff" -export const Diff = Revert.FileDiff +export const Diff = FileDiff.Info export type Diff = typeof Diff.Type diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 613b43b5f8..7765a396e8 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -46,7 +46,7 @@ const layer = Layer.effect( .flatMap((item) => item.info.watcher?.ignore ?? []) const home = path.resolve(location.directory) === path.resolve(os.homedir()) - if (!home) { + if (!home && location.vcs) { yield* watcher .subscribe({ path: location.directory, diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 19dcde8961..eea11c95f7 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -606,7 +606,7 @@ const layer = Layer.effect( file, ])).text return { - path: file, + file, status, additions: binary ? 0 : Number(stats[0] ?? 0), deletions: binary ? 0 : Number(stats[1] ?? 0), diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index fe2301651f..7e52aea140 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -5,29 +5,22 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" -import { makeLocationNode, Node } from "./effect/app-node" -import { httpClient } from "./effect/app-node-platform" +import { Node } from "./effect/app-node" import { EventV2 } from "./event" import { FileMutation } from "./file-mutation" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" -import { FSUtil } from "./fs-util" import { Generate } from "./generate" import { Form } from "./form" -import { Global } from "./global" -import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" +import { LocationWatcher } from "./filesystem/location-watcher" import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" -import { ModelsDev } from "./models-dev" -import { Npm } from "./npm" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" -import { PluginRuntime } from "./plugin/runtime" -import { SdkPlugins } from "./plugin/sdk" import { PluginSupervisor } from "./plugin/supervisor" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" @@ -35,7 +28,6 @@ import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" -import { Ripgrep } from "./ripgrep" import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" @@ -51,49 +43,11 @@ import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" -import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" -const pluginSupervisorNode = makeLocationNode({ - service: PluginSupervisor.Service, - layer: PluginSupervisor.layer, - deps: [ - PluginV2.node, - SdkPlugins.node, - AgentV2.node, - Catalog.node, - CommandV2.node, - Config.node, - EventV2.node, - FileMutation.node, - FileSystem.node, - FSUtil.node, - Global.node, - httpClient, - Image.node, - Integration.node, - Location.node, - LocationMutation.node, - ModelsDev.node, - Npm.node, - PermissionV2.node, - PluginRuntime.node, - Form.node, - ReadToolFileSystem.node, - Reference.node, - Ripgrep.node, - SessionInstructions.node, - SessionTodo.node, - Shell.node, - SkillV2.node, - ToolRegistry.toolsNode, - WebSearchTool.configNode, - ], -}) - const locationServiceNodes = [ Location.node, Config.node, @@ -104,7 +58,7 @@ const locationServiceNodes = [ Catalog.node, AISDK.node, PluginV2.node, - pluginSupervisorNode, + PluginSupervisor.node, ProjectCopy.node, ProjectCopy.refreshNode, FileSystemSearch.node, @@ -150,31 +104,44 @@ export type LocationError = LayerNode.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { + // Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded + // payloads omit optional keys) and `{ directory, workspaceID: undefined }` are + // different RcMap keys. The RcMap caches by the raw key before the build + // callback runs, so canonicalize at the map boundary to the key-present shape. + const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }) return Layer.effect( LocationServiceMap.Service, - LayerMap.make( - (ref: Location.Ref) => { - const startedAt = performance.now() - const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) - // Apply replacements during hoist, not afterward: replacements can - // introduce new tagged dependencies (Location.boundNode depends on - // Project), and the hoist walk is the only pass that can still slice - // those back out. - const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) + Effect.map( + LayerMap.make( + (ref: Location.Ref) => { + const startedAt = performance.now() + const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + // Apply replacements during hoist, not afterward: replacements can + // introduce new tagged dependencies (Location.boundNode depends on + // Project), and the hoist walk is the only pass that can still slice + // those back out. + const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) - return LayerNode.compile(location.node).pipe( - Layer.fresh, - Layer.tap(() => - Effect.logInfo("location services booted", { - directory: ref.directory, - workspaceID: ref.workspaceID, - durationMs: Math.round(performance.now() - startedAt), - }), - ), - Layer.provide(LayerNode.compile(location.hoisted)), - ) - }, - { idleTimeToLive: "60 minutes" }, + return LayerNode.compile(location.node).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("location services booted", { + directory: ref.directory, + workspaceID: ref.workspaceID, + durationMs: Math.round(performance.now() - startedAt), + }), + ), + Layer.provide(LayerNode.compile(location.hoisted)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + (inner) => ({ + ...inner, + get: (ref: Location.Ref) => inner.get(canonical(ref)), + contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)), + invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)), + }), ), ) } diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 02362f6428..5f48cba649 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -3,7 +3,7 @@ export * as MCPClient from "./client" import path from "node:path" import { execFile } from "node:child_process" import { pathToFileURL } from "node:url" -import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" @@ -21,6 +21,7 @@ import { ListToolsResultSchema, PromptListChangedNotificationSchema, PromptSchema, + ResourceListChangedNotificationSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, ToolListChangedNotificationSchema, @@ -31,7 +32,8 @@ import { ConfigMCP } from "../config/mcp" import { InstallationVersion } from "../installation/version" const DEFAULT_STARTUP_TIMEOUT = 30_000 -const DEFAULT_REQUEST_TIMEOUT = 30_000 +const DEFAULT_CATALOG_TIMEOUT = 30_000 +const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours type Transport = StdioClientTransport | StreamableHTTPClientTransport @@ -67,11 +69,13 @@ export interface ToolDefinition { export interface PromptDefinition { readonly name: string readonly description: string | undefined - readonly arguments: ReadonlyArray<{ - readonly name: string - readonly description: string | undefined - readonly required: boolean | undefined - }> | undefined + readonly arguments: + | ReadonlyArray<{ + readonly name: string + readonly description: string | undefined + readonly required: boolean | undefined + }> + | undefined } export interface PromptMessage { @@ -83,6 +87,28 @@ export interface PromptResult { readonly messages: ReadonlyArray } +export interface ResourceDefinition { + readonly name: string + readonly uri: string + readonly description: string | undefined + readonly mimeType: string | undefined +} + +export interface ResourceTemplateDefinition { + readonly name: string + readonly uriTemplate: string + readonly description: string | undefined + readonly mimeType: string | undefined +} + +export type ResourceContentPart = + | { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined } + | { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined } + +export interface ReadResourceResult { + readonly contents: ReadonlyArray +} + export type CallToolContent = | { readonly type: "text"; readonly text: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string } @@ -123,6 +149,12 @@ export interface Connection { readonly tools: () => Effect.Effect /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ readonly prompts: () => Effect.Effect + /** Lists the server's resources; returns [] when the server doesn't advertise resource support. */ + readonly resources: () => Effect.Effect + /** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */ + readonly resourceTemplates: () => Effect.Effect + /** Reads one resource; returns undefined when the server doesn't advertise resource support. */ + readonly readResource: (input: { readonly uri: string }) => Effect.Effect /** Invokes a prompt on the server. Interruption aborts the in-flight request. */ readonly prompt: (input: { readonly name: string @@ -140,6 +172,8 @@ export interface Connection { readonly onToolsChanged: (callback: () => void) => void /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ readonly onPromptsChanged: (callback: () => void) => void + /** Registers a callback fired when the server announces its resource catalog changed. */ + readonly onResourcesChanged: (callback: () => void) => void } /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ @@ -167,7 +201,8 @@ export const connect = Effect.fnUntraced(function* ( }, }) } - if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) + if (!URL.canParse(config.url)) + return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: config.headers ? { headers: config.headers } : undefined, authProvider, @@ -201,12 +236,10 @@ export const connect = Effect.fnUntraced(function* ( }).pipe(Effect.exit) if (Exit.isSuccess(exit)) { yield* Effect.addFinalizer(() => - cleanupStdioDescendants(transport).pipe( - Effect.andThen(Effect.promise(() => client.close())), - Effect.ignore, - ), + cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore), ) - const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT + const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT + const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT return { instructions: client.getInstructions()?.trim() || undefined, tools: () => @@ -218,11 +251,11 @@ export const connect = Effect.fnUntraced(function* ( async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { - return await client.listTools(params, { timeout: requestTimeout }) + return await client.listTools(params, { timeout: catalogTimeout }) } catch (error) { if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error return client.request({ method: "tools/list", params }, TolerantListToolsResult, { - timeout: requestTimeout, + timeout: catalogTimeout, }) } }, @@ -248,14 +281,16 @@ export const connect = Effect.fnUntraced(function* ( async (cursor) => { const params = cursor === undefined ? undefined : { cursor } return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, { - timeout: requestTimeout, + timeout: catalogTimeout, }) }, (result) => result.prompts, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( - Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), + Effect.tapError((error) => + Effect.logWarning("failed to list MCP prompts", { server, error: error.message }), + ), ) return prompts.map((prompt) => ({ name: prompt.name, @@ -267,13 +302,81 @@ export const connect = Effect.fnUntraced(function* ( })), })) }), + resources: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return [] + const resources = yield* Effect.tryPromise({ + try: () => + paginate( + (cursor) => + client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }), + (result) => result.resources, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list MCP resources", { server, error: error.message }), + ), + ) + return resources.map((resource) => ({ + name: resource.name, + uri: resource.uri, + description: resource.description, + mimeType: resource.mimeType, + })) + }), + resourceTemplates: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return [] + const templates = yield* Effect.tryPromise({ + try: () => + paginate( + (cursor) => + client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { + timeout: catalogTimeout, + }), + (result) => result.resourceTemplates, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }), + ), + ) + return templates.map((template) => ({ + name: template.name, + uriTemplate: template.uriTemplate, + description: template.description, + mimeType: template.mimeType, + })) + }), + readResource: (input) => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return undefined + const result = yield* Effect.tryPromise({ + try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }), + ), + ) + return { + contents: result.contents.map( + (part): ResourceContentPart => + "text" in part + ? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType } + : { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType }, + ), + } + }), prompt: (input) => Effect.tryPromise({ try: (signal) => client.request( { method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } }, GetPromptResultSchema, - { signal }, + { signal, timeout: executionTimeout }, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( @@ -287,8 +390,8 @@ export const connect = Effect.fnUntraced(function* ( client.callTool( { name: input.name, arguments: input.args ?? {} }, CallToolResultSchema, - // Keep progress tokens available without imposing a client timeout on tool execution. - { signal, resetTimeoutOnProgress: true, onprogress: () => {} }, + // Keep progress tokens available while enforcing a hard wall-clock execution timeout. + { signal, timeout: executionTimeout, onprogress: () => {} }, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( @@ -326,13 +429,14 @@ export const connect = Effect.fnUntraced(function* ( if (!client.getServerCapabilities()?.prompts?.listChanged) return client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) }, + onResourcesChanged: (callback) => { + if (!client.getServerCapabilities()?.resources?.listChanged) return + client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback()) + }, } satisfies Connection } - yield* cleanupStdioDescendants(transport).pipe( - Effect.andThen(Effect.promise(() => transport.close())), - Effect.ignore, - ) + yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore) const error = Cause.squash(exit.cause) if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 1ce8c3d3ec..7a99c15982 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class("MCP.PromptResult") messages: Schema.Array(PromptMessage), }) {} -export class Resource extends Schema.Class("MCP.Resource")({ - server: ServerName, - name: Schema.String, - uri: Schema.String, - description: Schema.String.pipe(Schema.optional), - mimeType: Schema.String.pipe(Schema.optional), -}) {} - -export class ResourceTemplate extends Schema.Class("MCP.ResourceTemplate")({ - server: ServerName, - name: Schema.String, - uriTemplate: Schema.String, - description: Schema.String.pipe(Schema.optional), - mimeType: Schema.String.pipe(Schema.optional), -}) {} - -export class ResourceCatalog extends Schema.Class("MCP.ResourceCatalog")({ - resources: Schema.Array(Resource), - templates: Schema.Array(ResourceTemplate), -}) {} - -export const ResourceContentPart = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("text"), - uri: Schema.String, - text: Schema.String, - mimeType: Schema.String.pipe(Schema.optional), - }), - Schema.Struct({ - type: Schema.Literal("blob"), - uri: Schema.String, - blob: Schema.String, - mimeType: Schema.String.pipe(Schema.optional), - }), -]).pipe(Schema.toTaggedUnion("type")) -export type ResourceContentPart = typeof ResourceContentPart.Type - -export class ResourceContent extends Schema.Class("MCP.ResourceContent")({ - server: ServerName, - uri: Schema.String, - contents: Schema.Array(ResourceContentPart), -}) {} +export const Resource = Mcp.Resource +export type Resource = Mcp.Resource +export const ResourceTemplate = Mcp.ResourceTemplate +export type ResourceTemplate = Mcp.ResourceTemplate +export const ResourceCatalog = Mcp.ResourceCatalog +export type ResourceCatalog = Mcp.ResourceCatalog +export const ResourceContentPart = Mcp.ResourceContentPart +export type ResourceContentPart = Mcp.ResourceContentPart +export const ResourceContent = Mcp.ResourceContent +export type ResourceContent = Mcp.ResourceContent export class NotFoundError extends Schema.TaggedErrorClass()("MCP.NotFoundError", { server: ServerName, @@ -415,6 +383,24 @@ export const layer = Layer.effect( ), }) + const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) => + Resource.make({ + server, + name: def.name, + uri: def.uri, + description: def.description, + mimeType: def.mimeType, + }) + + const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) => + ResourceTemplate.make({ + server, + name: def.name, + uriTemplate: def.uriTemplate, + description: def.description, + mimeType: def.mimeType, + }) + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => connection.tools().pipe( Effect.map((defs) => { @@ -443,6 +429,7 @@ export const layer = Layer.effect( entry.prompts = undefined entry.status = { status: "failed", error: "Connection closed" } fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) @@ -458,6 +445,10 @@ export const layer = Layer.effect( connection.onPromptsChanged(() => { fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) }) + connection.onResourcesChanged(() => { + if (entry.client !== connection) return + fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)) + }) } const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { @@ -501,6 +492,7 @@ export const layer = Layer.effect( // after the initial registration sweep and emits no list-changed notification would otherwise // stay invisible to the model. yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) + yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) return @@ -557,11 +549,6 @@ export const layer = Layer.effect( concurrency: "unbounded", discard: true, }) - const gate = Effect.fnUntraced(function* (server: ServerName | string) { - const target = yield* requireServer(server) - yield* Deferred.await(target.entry.startup) - }) - return Service.of({ servers: Effect.fn("MCP.servers")(function* () { const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b)) @@ -637,11 +624,54 @@ export const layer = Layer.effect( }), resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { yield* whenAllReady - return new ResourceCatalog({ resources: [], templates: [] }) + const catalogs = yield* Effect.forEach( + Array.from(runtime), + ([name, entry]) => { + if (!entry.client) return Effect.succeed({ resources: [], templates: [] }) + return Effect.all( + { + resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))), + templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map((catalog) => ({ + resources: catalog.resources.map((def) => toResource(name, def)), + templates: catalog.templates.map((def) => toResourceTemplate(name, def)), + })), + ) + }, + { concurrency: "unbounded" }, + ) + return ResourceCatalog.make({ + resources: catalogs + .flatMap((catalog) => catalog.resources) + .toSorted( + (a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), + ), + templates: catalogs + .flatMap((catalog) => catalog.templates) + .toSorted( + (a, b) => + a.server.localeCompare(b.server) || + a.name.localeCompare(b.name) || + a.uriTemplate.localeCompare(b.uriTemplate), + ), + }) }), readResource: Effect.fn("MCP.readResource")(function* (input) { - yield* gate(input.server) - return undefined + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .readResource({ uri: input.uri }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return ResourceContent.make({ + server: target.name, + uri: input.uri, + contents: result.contents, + }) }), }) }), diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index a88aebfe07..faeee213fb 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -2,6 +2,7 @@ import path from "path" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" +import { Money } from "@opencode-ai/schema/money" import { Global } from "./global" import { Flag } from "./flag/flag" import { Flock } from "./util/flock" @@ -18,10 +19,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` const CostTier = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Finite, @@ -29,17 +30,17 @@ const CostTier = Schema.Struct({ }) const Cost = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), tiers: Schema.optional(Schema.Array(CostTier)), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), }), ), }) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 15d9047c4f..99743abd2f 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -67,7 +67,13 @@ export class CorrectedError extends Schema.TaggedErrorClass()("P export class BlockedError extends Schema.TaggedErrorClass()("PermissionV2.BlockedError", { rules: Permission.Ruleset, -}) {} + permission: Schema.String, + resources: Schema.Array(Schema.String), +}) { + override get message() { + return `Permission denied: ${this.permission}` + } +} export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { requestID: ID, @@ -201,6 +207,8 @@ const layer = Layer.effect( if (result.effect === "deny") { return yield* new BlockedError({ rules: relevant(input, result.rules), + permission: input.action, + resources: input.resources, }) } if (result.effect === "allow") return diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 907920c10e..4e0858fd16 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,6 +1,6 @@ export * as PluginV2 from "./plugin" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "./effect/app-node" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" @@ -18,6 +18,7 @@ import { SkillV2 } from "./skill" import { State } from "./state" import { ToolRegistry } from "./tool/registry" import { ToolHooks } from "./tool/hooks" +import { PluginHooks } from "./plugin/hooks" export interface Interface { readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect @@ -57,12 +58,13 @@ const layer = Layer.effect( generation.length === definitions.length && generation.every( (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version, - ) + ) && + definitions.every((definition) => active.has(definition.id)) ) { return } generation = undefined - const exit = yield* State.batch( + yield* State.batch( Effect.gen(function* () { const scopes = Array.from(active.values()).toReversed() active.clear() @@ -81,13 +83,17 @@ const layer = Layer.effect( Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.exit, ) - if (Exit.isFailure(loaded)) return loaded + if (Exit.isFailure(loaded)) { + yield* Effect.logWarning("failed to load plugin", { + "plugin.id": definition.id, + cause: loaded.cause, + }) + continue + } active.set(definition.id, child) } - return Exit.void }), ) - if (Exit.isFailure(exit)) return yield* exit generation = definitions.map((definition) => ({ id: definition.id, ...(definition.version === undefined ? {} : { version: definition.version }), @@ -131,6 +137,7 @@ export const node = makeLocationNode({ SkillV2.node, ToolRegistry.toolsNode, ToolHooks.node, + PluginHooks.node, PluginRuntime.node, ], }) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 7549089bf3..e6e03a4f15 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -125,6 +125,7 @@ export const Plugin = define({ yield* ctx.agent.transform((draft) => { draft.update(AgentV2.defaultID, (item) => { + item.name = AgentV2.Name.make("Build") item.description = "The default agent. Executes tools based on configured permissions." item.mode = "primary" item.permissions.push( @@ -136,6 +137,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("plan"), (item) => { + item.name = AgentV2.Name.make("Plan") item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -155,6 +157,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("general"), (item) => { + item.name = AgentV2.Name.make("General") item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" @@ -167,6 +170,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("explore"), (item) => { + item.name = AgentV2.Name.make("Explore") item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -189,6 +193,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("compaction"), (item) => { + item.name = AgentV2.Name.make("Compaction") item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION @@ -196,6 +201,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("title"), (item) => { + item.name = AgentV2.Name.make("Title") item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE @@ -203,6 +209,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("summary"), (item) => { + item.name = AgentV2.Name.make("Summary") item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts new file mode 100644 index 0000000000..4ac437d392 --- /dev/null +++ b/packages/core/src/plugin/hooks.ts @@ -0,0 +1,67 @@ +export * as PluginHooks from "./hooks" + +import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" +import { Context, Effect, Layer, Scope } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { State } from "../state" + +export interface Domains { + readonly aisdk: AISDKHooks + readonly session: SessionHooks + readonly tool: ToolHooks +} + +type Callback = (event: Event) => Effect.Effect + +export interface Interface { + readonly register: ( + domain: Domain, + name: Name, + callback: Callback, + ) => Effect.Effect + readonly trigger: ( + domain: Domain, + name: Name, + event: Domains[Domain][Name], + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/PluginHooks") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const callbacks = new Map() + const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}` + + const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) { + const scope = yield* Scope.Scope + const id = key(domain, name) + let active = true + callbacks.set(id, [...(callbacks.get(id) ?? []), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + const next = (callbacks.get(id) ?? []).filter((item) => item !== callback) + if (next.length === 0) callbacks.delete(id) + else callbacks.set(id, next) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) { + for (const callback of callbacks.get(key(domain, name)) ?? []) { + const result: Effect.Effect = Reflect.apply(callback, undefined, [event]) + yield* result + } + return event + }) + + return Service.of({ register, trigger }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 01ea0da0d3..a109dd02e7 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,6 @@ export * as PluginHost from "./host" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -22,6 +22,7 @@ import { Tool } from "../tool/tool" import { Tools } from "../tool/tools" import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" +import { PluginHooks } from "./hooks" const mutable = (value: T) => value as DeepMutable export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { @@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const skill = yield* SkillV2.Service const tools = yield* Tools.Service const toolHooks = yield* ToolHooks.Service + const hooks = yield* PluginHooks.Service const runtime = yield* PluginRuntime.Service const locationInfo = () => new Location.Info({ @@ -43,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int workspaceID: location.workspaceID, project: location.project, }) - const locationRef = (input?: Parameters[0]) => + const locationRef = (input?: Parameters[0]) => input?.location === undefined ? undefined : Location.Ref.make({ @@ -79,32 +81,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, aisdk: { - sdk: (callback) => - aisdk.hook.sdk((event) => { + hook: (name, callback) => { + if (name === "sdk") { + return aisdk.hook.sdk((event) => { + const output = { + model: mutable(event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + return Reflect.apply(callback, undefined, [output]).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }) + } + return aisdk.hook.language((event) => { const output = { model: mutable(event.model), - package: event.package, options: event.options, sdk: event.sdk, - } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }), - language: (callback) => - aisdk.hook.language((event) => { - const output = { - model: mutable(event.model), - sdk: event.sdk, - options: event.options, language: event.language, } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => (event.language = output.language))), ) - }), + }) + }, }, catalog: { provider: { @@ -164,25 +166,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), - connectKey: (input) => - integration.connection.key({ - integrationID: Integration.ID.make(input.integrationID), - key: input.key, - label: input.label, - }), - connectOauth: (input) => - response( - integration.connection.oauth({ + connect: { + key: (input) => + integration.connection.key({ integrationID: Integration.ID.make(input.integrationID), - methodID: Integration.MethodID.make(input.methodID), - inputs: input.inputs, + key: input.key, label: input.label, }), - ), - attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), - attemptComplete: (input) => - integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), - attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + oauth: (input) => + response( + integration.connection.oauth({ + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + inputs: input.inputs, + label: input.label, + }), + ), + }, + attempt: { + status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + complete: (input) => + integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), + cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + }, reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), @@ -317,11 +323,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int registrations, (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), { discard: true }, - ) + ).pipe(Effect.orDie) + return { dispose: Effect.void } }), - execute: { - before: (callback) => - toolHooks.hook.before((event) => { + hook: (name, callback) => { + if (name === "execute.before") { + return toolHooks.hook.before((event) => { const output = { tool: event.tool, sessionID: event.sessionID, @@ -330,38 +337,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int toolCallID: event.toolCallID, input: event.input, } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => (event.input = output.input))), ) - }), - after: (callback) => - toolHooks.hook.after((event) => { - const output = { - tool: event.tool, - sessionID: event.sessionID, - agent: event.agent, - assistantMessageID: event.assistantMessageID, - toolCallID: event.toolCallID, - input: event.input, - result: event.result, - output: event.output, - outputPaths: event.outputPaths, - } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => - Effect.sync(() => { - event.result = output.result - event.output = output.output - event.outputPaths = output.outputPaths - }), - ), - ) - }), + }) + } + return toolHooks.hook.after((event) => { + const output = { + tool: event.tool, + sessionID: event.sessionID, + agent: event.agent, + assistantMessageID: event.assistantMessageID, + toolCallID: event.toolCallID, + input: event.input, + result: event.result, + output: event.output, + outputPaths: event.outputPaths, + } + return Reflect.apply(callback, undefined, [output]).pipe( + Effect.tap(() => + Effect.sync(() => { + event.result = output.result + event.output = output.output + event.outputPaths = output.outputPaths + }), + ), + ) + }) }, }, session: { + hook: (name, callback) => hooks.register("session", name, callback), create: (input) => runtime.session.create({ id: input?.id, @@ -375,5 +381,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, - } satisfies PluginContext + } satisfies Plugin.Context }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2789cc3c2e..ad5553928a 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,6 +1,6 @@ export * as PluginInternal from "./internal" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { AgentV2 } from "../agent" @@ -31,7 +31,7 @@ import { SessionInstructions } from "../session/instructions" import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" -import { ApplyPatchTool } from "../tool/apply-patch" +import { PatchTool } from "../tool/patch" import { EditTool } from "../tool/edit" import { GlobTool } from "../tool/glob" import { GrepTool } from "../tool/grep" @@ -127,7 +127,7 @@ const pre = [ SkillPlugin.Plugin, ModelsDevPlugin, ...ProviderPlugins, - ApplyPatchTool.Plugin, + PatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, GrepTool.Plugin, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 67cf0069dc..ef59535e81 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import { Money } from "@opencode-ai/schema/money" +import type { ModelInfo } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" import { ModelV2 } from "../model" @@ -11,13 +12,13 @@ function released(date: string) { return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { +function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] { const base = { - input: input?.input ?? 0, - output: input?.output ?? 0, + input: input?.input ?? Money.USDPerMillionTokens.zero, + output: input?.output ?? Money.USDPerMillionTokens.zero, cache: { - read: input?.cache_read ?? 0, - write: input?.cache_write ?? 0, + read: input?.cache_read ?? Money.USDPerMillionTokens.zero, + write: input?.cache_write ?? Money.USDPerMillionTokens.zero, }, } return [ @@ -27,8 +28,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { input: item.input, output: item.output, cache: { - read: item.cache_read ?? 0, - write: item.cache_write ?? 0, + read: item.cache_read ?? Money.USDPerMillionTokens.zero, + write: item.cache_write ?? Money.USDPerMillionTokens.zero, }, })) ?? []), ...(input?.context_over_200k @@ -41,8 +42,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { input: input.context_over_200k.input, output: input.context_over_200k.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, + write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, }, }, ] @@ -50,13 +51,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { ] } -function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { +function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) { if (!override) return base const next = cost(override) const [baseDefault, ...baseTiers] = base const [nextDefault, ...nextTiers] = next - const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` - const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({ ...left, ...right, tier: right.tier ?? left.tier, @@ -67,12 +68,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] const current = tiers.get(tierKey(item)) tiers.set(tierKey(item), current ? merge(current, item) : item) } - return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] + return [ + merge( + baseDefault ?? { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + nextDefault, + ), + ...tiers.values(), + ] } const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { +function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { const npm = model.provider?.npm ?? provider.npm const options = model.reasoning_options ?? [] const effort = options.find((option) => option.type === "effort") @@ -117,7 +131,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2. function budgetVariants( npm: string | undefined, option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { +): NonNullable { const max = option.max const high = option.max === undefined @@ -146,7 +160,7 @@ function modeName(model: ModelsDev.Model, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } -function mergeVariants(model: ModelV2Info, next: NonNullable) { +function mergeVariants(model: ModelInfo, next: NonNullable) { const variants = model.variants ?? [] const existing = new Map(variants.map((variant) => [variant.id, variant])) const nextIDs = new Set(next.map((variant) => variant.id)) @@ -157,13 +171,13 @@ function mergeVariants(model: ModelV2Info, next: NonNullable["modes"]>[string]["provider"] - readonly variants?: NonNullable + readonly variants?: NonNullable } = {}, ) { draft.name = input.name ?? model.name diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index bcd3db1323..8eafe52734 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,11 +1,12 @@ export * as PluginPromise from "./promise" -import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect, Scope, Stream } from "effect" 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 /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only @@ -16,8 +17,8 @@ type Registration = { readonly dispose: () => Promise } * preserves boot-time batching, so Promise-plugin transforms still coalesce * into one reload per domain. */ -export function fromPromise(plugin: Plugin) { - return define({ +export function fromPromise(plugin: PromisePlugin) { + return Plugin.define({ id: plugin.id, effect: (host) => Effect.gen(function* () { @@ -43,7 +44,7 @@ export function fromPromise(plugin: Plugin) { }), ) - const context2: PluginContext = { + const context2: PromisePluginContext = { options: host.options, agent: { list: (input) => run(host.agent.list(input)), @@ -51,10 +52,8 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.agent.reload()), }, aisdk: { - sdk: (callback) => - register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), - language: (callback) => - register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + hook: (name, callback) => + register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, catalog: { provider: { @@ -79,11 +78,15 @@ export function fromPromise(plugin: Plugin) { integration: { list: (input) => run(host.integration.list(input)), get: (input) => run(host.integration.get(input)), - connectKey: (input) => run(host.integration.connectKey(input)), - connectOauth: (input) => run(host.integration.connectOauth(input)), - attemptStatus: (input) => run(host.integration.attemptStatus(input)), - attemptComplete: (input) => run(host.integration.attemptComplete(input)), - attemptCancel: (input) => run(host.integration.attemptCancel(input)), + connect: { + key: (input) => run(host.integration.connect.key(input)), + oauth: (input) => run(host.integration.connect.oauth(input)), + }, + 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)), + }, transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { @@ -104,12 +107,19 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.skill), reload: () => run(host.skill.reload()), }, + tool: { + transform: transform(host.tool), + hook: (name, callback) => + 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)), + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, } diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index 607e565d4a..ea37b00453 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AlibabaPlugin = define({ id: "opencode.provider.alibaba", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 5bbe953f80..ecc137bbde 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } @@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({ evt.sdk = mod.createAmazonBedrock(options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if ( diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 4c5938e51f..bbf0d56dfb 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -17,7 +17,8 @@ export const AnthropicPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 65a40c023b..f50bbdb1ba 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -26,7 +26,8 @@ export const AzurePlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { @@ -44,7 +45,8 @@ export const AzurePlugin = define({ evt.sdk = mod.createAzure(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage( @@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({ }) } }) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage( diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 42fea46550..13614d6be0 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -14,7 +14,8 @@ export const CerebrasPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afab0c36df..0602549614 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CloudflareAIGatewayPlugin = define({ id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 9b4558b5d1..c3aa50fbb4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({ if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -35,7 +36,8 @@ export const CloudflareWorkersAIPlugin = define({ ) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 8b0831604a..9284defcb1 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CoherePlugin = define({ id: "opencode.provider.cohere", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index e8316012ad..f4ddf97859 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const DeepInfraPlugin = define({ id: "opencode.provider.deepinfra", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index 2e51674ba0..ae20d0d020 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({ id: "opencode.provider.dynamic", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.sdk) return diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 07249391a0..b67c0ef179 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GatewayPlugin = define({ id: "opencode.provider.gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index ab9364260c..139e6efd2d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({ model.enabled = false }) }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 1a8a4a1231..a413c85d7d 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) @@ -31,7 +32,8 @@ export const GitLabPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 2c307a8725..ffc29f4bf3 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) @@ -104,7 +105,8 @@ export const GoogleVertexPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) @@ -135,7 +137,8 @@ export const GoogleVertexAnthropicPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) @@ -161,7 +164,8 @@ export const GoogleVertexAnthropicPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 3d2013a523..c62d962eb5 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GooglePlugin = define({ id: "opencode.provider.google", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index d84ece2151..51b8e4c42f 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GroqPlugin = define({ id: "opencode.provider.groq", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index 92bc09abec..f5de664432 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const MistralPlugin = define({ id: "opencode.provider.mistral", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 3854bdcde2..76646b4e45 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenAICompatiblePlugin = define({ id: "opencode.provider.openai-compatible", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 72fe8ed646..5e4a1b08ef 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -210,14 +210,16 @@ export const OpenAIPlugin = define({ Effect.forkScoped({ startImmediately: true }), ) yield* refresh().pipe(Effect.forkScoped) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) evt.sdk = mod.createOpenAI(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.openai) return evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 58690df0b2..cca6a2dfa7 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -10,6 +10,7 @@ import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { ConfigProviderV1 } from "../../v1/config/provider" +import { Money } from "@opencode-ai/schema/money" import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options" import { ConfigV1 } from "../../v1/config/config" @@ -220,20 +221,23 @@ function withoutCredentials(body: Readonly> | undefined) function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { const base = { - input: input.input, - output: input.output, - cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 }, + input: Money.USDPerMillionTokens.make(input.input), + output: Money.USDPerMillionTokens.make(input.output), + cache: { + read: Money.USDPerMillionTokens.make(input.cache_read ?? 0), + write: Money.USDPerMillionTokens.make(input.cache_write ?? 0), + }, } if (!input.context_over_200k) return [base] return [ base, { tier: { type: "context" as const, size: 200_000 }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + input: Money.USDPerMillionTokens.make(input.context_over_200k.input), + output: Money.USDPerMillionTokens.make(input.context_over_200k.output), cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0), + write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0), }, }, ] diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index c27cc30207..f00386a94a 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({ } } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 9eb5b1e246..36cafdb2d1 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const PerplexityPlugin = define({ id: "opencode.provider.perplexity", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 4a8c26e82a..559493787d 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -8,7 +8,8 @@ export const SapAICorePlugin = define({ id: "opencode.provider.sap-ai-core", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = @@ -37,7 +38,8 @@ export const SapAICorePlugin = define({ ) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 2e6cc9f9b4..45c5f095ee 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) { export const SnowflakeCortexPlugin = define({ id: "opencode.provider.snowflake-cortex", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index d9454cfd24..ea43db9e4a 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const TogetherAIPlugin = define({ id: "opencode.provider.togetherai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index c9d5b163ae..9930ce831d 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VenicePlugin = define({ id: "opencode.provider.venice", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 70a27c8f67..fc392fe5e1 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -14,7 +14,8 @@ export const VercelPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index fb1d33e86e..724a3d2567 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index f6ceb06a8f..3dd42a8037 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -1,20 +1,12 @@ export * as SdkPlugins from "./sdk" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +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" export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} }) -export interface Store { - readonly plugins: Map -} - -export const makeStore = (): Store => ({ plugins: new Map() }) - -const defaultStore = makeStore() - /** * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * so `PluginSupervisor` can add them on every Location boot through the ordinary @@ -22,10 +14,9 @@ const defaultStore = makeStore() * config. Registration publishes an unlocated update so every booted Location * reloads its plugin generation from the shared store. * - * The store is shared explicitly between the SDK construction graph and the - * embedded route graph because `LocationServiceMap` builds Location layers lazily - * in a nested graph. Each embedded SDK creates its own store, so instances do not - * see each other's contributions. + * Each host-global layer owns one private store. Location graphs reuse that + * layer through Effect's memoization, so separate hosts remain isolated while + * every Location in one host sees the same registrations. */ export interface Interface { readonly register: (plugin: Plugin) => Effect.Effect @@ -34,26 +25,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SdkPlugins") {} -export const layerWithStore = (store: Store) => - Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2.Service - yield* Effect.addFinalizer(() => +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const plugins = new Map() + return Service.of({ + register: (plugin) => Effect.sync(() => { - store.plugins.clear() - }), - ) - return Service.of({ - register: (plugin) => - Effect.sync(() => { - store.plugins.set(plugin.id, plugin) - }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), - all: () => [...store.plugins.values()], - }) - }), - ) - -export const layer = layerWithStore(defaultStore) + plugins.set(plugin.id, plugin) + }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), + all: () => [...plugins.values()], + }) + }), +) export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index f6ca2ef4af..439b77fd36 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -13,14 +13,14 @@ import { FSUtil } from "../fs-util" import os from "os" import path from "path" import { fileURLToPath } from "url" -import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } +import opencodeContent from "./skill/opencode.md" with { type: "text" } import reportContent from "./skill/report.md" with { type: "text" } -export const CustomizeOpencodeContent = customizeOpencodeContent +export const OpencodeContent = opencodeContent export const ReportContent = reportContent -const CUSTOMIZE_OPENCODE_DESCRIPTION = - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +export const OpencodeDescription = + "Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app." const REPORT_DESCRIPTION = "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI." @@ -33,10 +33,11 @@ export const Plugin = define({ SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ - name: "customize-opencode", - description: CUSTOMIZE_OPENCODE_DESCRIPTION, - location: AbsolutePath.make("/builtin/customize-opencode.md"), - content: CustomizeOpencodeContent, + id: SkillV2.ID.make("opencode"), + name: SkillV2.Name.make("OpenCode"), + description: OpencodeDescription, + location: AbsolutePath.make("/builtin/opencode.md"), + content: OpencodeContent, }), }), ) @@ -44,7 +45,8 @@ export const Plugin = define({ SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ - name: "report", + id: SkillV2.ID.make("report"), + name: SkillV2.Name.make("Report"), description: REPORT_DESCRIPTION, slash: true, location: AbsolutePath.make("/builtin/report.md"), @@ -103,15 +105,22 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* ( }) function terminal() { - return [ - process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, - process.env.TERM ? `TERM=${process.env.TERM}` : undefined, - process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, - ] - .filter((item): item is string => item !== undefined) - .join(", ") || "Unavailable: terminal environment variables are not set" + return ( + [ + process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, + process.env.TERM ? `TERM=${process.env.TERM}` : undefined, + process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, + ] + .filter((item): item is string => item !== undefined) + .join(", ") || "Unavailable: terminal environment variables are not set" + ) } function shell() { - return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set" + return ( + process.env.SHELL ?? + process.env.ComSpec ?? + process.env.COMSPEC ?? + "Unavailable: shell environment variable is not set" + ) } diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md deleted file mode 100644 index 6932dbfd54..0000000000 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ /dev/null @@ -1,452 +0,0 @@ - - -# Customizing opencode - -opencode validates its own config strictly and refuses to start when a field -is wrong. The shapes below cover the common surface area, but they are a -**summary, not the source of truth**. - -## Full schema reference - -The authoritative list of every config option — with field types, enums, -defaults, and descriptions — lives in the published JSON Schema: - -**** - -If a field is not documented in this skill, or you need to confirm an exact -shape before writing config, **fetch that URL and read the schema directly** -rather than guessing. opencode hard-fails on invalid config, so the cost of a -wrong shape is a broken startup. - -Independently, every `opencode.json` should declare -`"$schema": "https://opencode.ai/config.json"` so the user's editor catches -mistakes as they type. - -## Applying changes - -Config is loaded once when opencode starts and is not hot-reloaded. After -saving changes to `opencode.json`, an agent file, a skill, a plugin, or any -other config-time file, **tell the user to quit and restart opencode** for -the changes to take effect. The running session will keep using the -already-loaded config until then. - -## Where files live - -| Scope | Path | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | -| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | -| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | -| Global agents | `~/.config/opencode/agent(s)/.md` | -| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | -| Global commands | `~/.config/opencode/command(s)/.md` | -| Project skills | `.opencode/skill(s)//SKILL.md` | -| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | -| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | - -Configs from each scope are deep-merged. Project overrides global. Unknown -top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. - -## opencode.json - -Every field is optional. - -```json -{ - "$schema": "https://opencode.ai/config.json", - "username": "string", - "model": "provider/model-id", - "small_model": "provider/model-id", - "default_agent": "agent-name", - "shell": "/bin/zsh", - "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", - "share": "manual" | "auto" | "disabled", - "autoupdate": true | false | "notify", - "snapshot": true, - "instructions": ["AGENTS.md", "docs/style.md"], - - "skills": { - "paths": [".opencode/skills", "/abs/path/to/skills"], - "urls": ["https://example.com/.well-known/skills/"] - }, - - "references": { - "docs": { - "path": "../docs", - "description": "Use for product behavior and documentation conventions" - }, - "sdk": { - "repository": "owner/sdk", - "branch": "main", - "description": "Use for SDK implementation details", - "hidden": true - } - }, - - "agent": { - "my-agent": { - "model": "anthropic/claude-sonnet-4-6", - "mode": "subagent", - "description": "...", - "permission": { "edit": "deny" } - } - }, - - "command": { - "deploy": { "description": "...", "template": "..." } - }, - - "provider": { - "anthropic": { "options": { "apiKey": "..." } } - }, - "disabled_providers": ["openai"], - "enabled_providers": ["anthropic"], - - "mcp": { - "playwright": { - "type": "local", - "command": ["npx", "-y", "@playwright/mcp"], - "enabled": true, - "env": {} - }, - "remote-thing": { - "type": "remote", - "url": "https://...", - "headers": { "Authorization": "Bearer ..." } - } - }, - - "plugin": [ - "opencode-gemini-auth", - "opencode-foo@1.2.3", - "./local-plugin.ts", - ["opencode-bar", { "option": "value" }] - ], - - "permission": { - "edit": "deny", - "bash": { "git *": "allow", "*": "ask" } - }, - - "formatter": false, - "lsp": false, - - "experimental": { - "primary_tools": ["edit"], - "mcp_timeout": 30000 - }, - - "tool_output": { "max_lines": 200, "max_bytes": 8192 }, - - "compaction": { "auto": true, "tail_turns": 15 } -} -``` - -Shape notes worth being explicit about: - -- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. -- `skills` is an object with `paths` and/or `urls`, not an array. -- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. -- `agent` is an object keyed by agent name, not an array. -- `command` is an object keyed by command name, not an array. -- `plugin` is an array of strings or `[name, options]` tuples, not an object. -- `mcp[name].command` is an array of strings, never a single string. `type` is required. -- `permission` is either a string action or an object keyed by tool name. - -## Skills - -opencode's skill loader scans for `**/SKILL.md` inside skill directories. The -file is named `SKILL.md` exactly, and lives in its own folder named after the -skill: - -``` -.opencode/skills/my-skill/SKILL.md -``` - -Frontmatter: - -```markdown ---- -name: my-skill -description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. ---- - -# My Skill - -(skill body in markdown: instructions, examples, references) -``` - -- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. -- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. -- Optional: `license`, `compatibility`, `metadata` (string-string map). - -Register skills from non-default locations via `skills.paths` (scanned -recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of -skills). - -## References - -References make local directories and Git repositories outside the active -project available as supporting context. Configure them under `references`, -keyed by the alias used in `@` autocomplete: - -```json -{ - "references": { - "docs": { - "path": "../product-docs", - "description": "Use for product behavior and terminology" - }, - "effect": { - "repository": "Effect-TS/effect", - "branch": "main", - "description": "Use for Effect implementation details" - } - } -} -``` - -Local `path` values may be relative to the declaring config, absolute, or use -`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub -`owner/repo` shorthand; `branch` is optional. Both forms support optional -`description` and `hidden` fields. - -- Only references with a `description` are advertised to agents in system context. -- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path. -- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply. -- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories. - -## Agents - -Two ways to define an agent. Use the file form for anything non-trivial. - -### Inline (in `opencode.json`) - -```json -{ - "agent": { - "my-reviewer": { - "description": "Reviews PRs for style violations.", - "mode": "subagent", - "model": "anthropic/claude-sonnet-4-6", - "permission": { "edit": "deny", "bash": "ask" }, - "prompt": "You are a strict PR reviewer..." - } - } -} -``` - -### File - -``` -.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md -``` - -```markdown ---- -description: Reviews PRs for style violations. -mode: subagent -model: anthropic/claude-sonnet-4-6 -permission: - edit: deny - bash: ask ---- - -You are a strict PR reviewer. Focus on... -``` - -The file body becomes the agent's `prompt`. Do not also put `prompt:` in the -frontmatter. - -`mode` is one of `"primary"`, `"subagent"`, `"all"`. - -Allowed top-level frontmatter fields: `name, model, variant, description, mode, -hidden, color, steps, options, permission, disable, temperature, top_p`. Any -unknown field is silently routed into `options`. - -To disable a built-in agent: `agent: { build: { disable: true } }`, or in a -file, `disable: true` in frontmatter. - -`default_agent` must point to a non-hidden, primary-mode agent. - -### Built-in agents - -opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents: -`compaction`, `title`, `summary`. To override a built-in's fields, define the -same key in `agent: { : { ... } }`. - -## Commands - -opencode's command loader scans for `**/*.md` inside command directories. The -file is named after the command, and lives directly inside the `command` folder: - -``` -.opencode/command/deploy.md -``` - -Frontmatter: - -```markdown ---- -description: One sentence describing what the command does. -agent: build -model: anthropic/claude-sonnet-4-6 ---- - -(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input) -``` - -- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. -- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. -- Optional: `description`, `agent`, `model`, `variant`, `subtask`. - -## Plugins - -`plugin:` is an array. Each entry is one of: - -```json -"plugin": [ - "opencode-gemini-auth", // npm spec, latest - "opencode-foo@1.2.3", // npm spec, pinned - "./local-plugin.ts", // file path, relative to the declaring config - "file:///abs/path/plugin.js", // file URL - ["opencode-bar", { "key": "val" }] // tuple form with options -] -``` - -Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in -`.opencode/plugin/` or `.opencode/plugins/`. - -A plugin module exports `default` (or any named export) of type -`Plugin = (input: PluginInput, options?) => Promise`. The export is a -function, not a plain object literal, and the function returns an object -(return `{}` if there is nothing to register). - -```ts -import type { Plugin } from "@opencode-ai/plugin" - -export default (async ({ client, project, directory, $ }) => { - return { - config: (cfg) => { - // cfg is the live merged config; mutate fields here. - }, - "tool.execute.before": async (input, output) => { - // mutate output.args before the tool runs - }, - } -}) satisfies Plugin -``` - -Hook surface (mutate `output` in place; return `void`): - -- `event(input)`: every bus event -- `config(cfg)`: once on init with the merged config -- `chat.message`, `chat.params`, `chat.headers` -- `tool.execute.before`, `tool.execute.after` -- `tool.definition` -- `command.execute.before` -- `shell.env` -- `permission.ask` -- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, - `experimental.session.compacting`, `experimental.compaction.autocontinue`, - `experimental.text.complete` - -Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, -`auth: { ... }`, `provider: { ... }`. - -## MCP servers - -`mcp:` is an object keyed by server name. Each server is discriminated by -`type`: - -```json -{ - "mcp": { - "playwright": { - "type": "local", - "command": ["npx", "-y", "@playwright/mcp"], - "enabled": true, - "env": { "BROWSER": "chromium" } - }, - "github": { - "type": "remote", - "url": "https://...", - "enabled": true, - "headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" } - }, - "old-server": { "enabled": false } - } -} -``` - -`command` is an array of strings. `type` is required. Use `enabled: false` to -disable a server inherited from a parent config. String values such as header -tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style -`${VAR}` is not substituted. - -## Permissions - -```json -"permission": { - "edit": "deny", - "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, - "external_directory": { "~/secrets/**": "deny", "*": "allow" } -} -``` - -Actions: `"allow"`, `"ask"`, `"deny"`. - -Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an -object `{ pattern: action }`. Within an object, **insertion order matters**. -opencode evaluates the LAST matching rule, so put broad rules first and narrow -rules last. - -`permission: "allow"` (a string at the top level) is shorthand for "allow -everything" and is rarely what the user wants. - -Known permission keys: `read, edit, glob, grep, list, bash, task, -external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop, -skill`. Some of these (`todowrite, -question, webfetch, websearch, doom_loop`) only accept a flat -action, not a per-pattern object. - -`external_directory` patterns are filesystem paths (use `~/`, absolute paths, -or globs like `~/projects/**`). - -Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on -the `plan` agent's permission ruleset (`edit: deny *`). - -## Escape hatches - -When a user's config is broken and opencode won't start, these env vars help: - -- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` - and start from globals only. Run from the project directory, opencode loads, - the user edits the broken file, then they restart without the flag. -- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. -- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: - inject inline JSON as a final local-scope merge. -- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. -- `OPENCODE_PURE=1`: skip external plugins entirely. -- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, - `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under - `~/.claude/` and `~/.agents/`. - -## When proposing edits - -- Validate against the schema before writing. If you are unsure of a field's - exact shape, or the field is not covered in this skill, fetch - `https://opencode.ai/config.json` and read the schema rather than guessing. -- Preserve `$schema` and any existing fields the user did not ask to change. -- For agent, command, skill, and plugin definitions, prefer creating new files - in the correct location over inlining everything in `opencode.json`. -- If the user's existing config is malformed, point them at the env-var escape - hatches above so they can edit from inside opencode without breaking their - session. -- After saving any config change, remind the user to quit and restart opencode - — running sessions keep using the already-loaded config. diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md new file mode 100644 index 0000000000..fd58be201e --- /dev/null +++ b/packages/core/src/plugin/skill/opencode.md @@ -0,0 +1,112 @@ +# OpenCode + +Use this guide as the starting point for work involving OpenCode itself. It +covers the core concepts needed to configure and customize OpenCode, extend it +with plugins, and build integrations with the OpenCode SDK, clients, and API. + +Full documentation is available at . Consult +it when this overview does not contain enough detail for the task. + +## Configuration + +OpenCode configuration uses JSON or JSONC. Include the published schema so the +user's editor can validate fields and provide autocomplete: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json" +} +``` + +Global configuration lives at `~/.config/opencode/opencode.json(c)` and applies +to every project for that user. Project configuration can live in any directory +as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages +in a monorepo. + +When OpenCode starts, it searches upward from the current directory for project +configuration and merges the files it finds with the global configuration. + +Common configuration fields include `model`, `default_agent`, `permissions`, +`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`, +`references`, `formatter`, and `lsp`. + +Do not guess field names or shapes. Use + as the source of truth and preserve unrelated +settings when editing an existing file. + +See the [full configuration guide](https://opencode.mintlify.site/config) for +every field, examples, config locations, and links to dedicated feature guides. + +## Service + +OpenCode uses a client-server architecture. Interfaces such as the TUI connect +to a background OpenCode service, which owns sessions, configuration, plugins, +permissions, and tool execution. + +Configuration and related files are typically watched and reloaded while the +service is running. If a change does not appear, restart the service: + +```sh +opencode2 service restart +``` + +Check its status after restarting: + +```sh +opencode2 service status +``` + +## API + +OpenCode exposes an HTTP API from its server. The API is described by an +OpenAPI document available from the running server at `/openapi.json`. + +Use OpenCode's built-in `api` command for local requests. It discovers the same +background server used by the TUI, starts it when necessary, and applies the +server's authentication headers automatically. + +Call an endpoint with an HTTP method and path: + +```sh +opencode2 api get /api/health +``` + +Pass a request body with `--data` or `-d`, and additional headers with +`--header` or `-H`: + +```sh +opencode2 api post /api/example --data '{"key":"value"}' +opencode2 api get /api/example --header 'X-Example:value' +``` + +Request bodies default to `Content-Type: application/json`. When OpenCode is +connected to an explicit server instead of its managed background service, use +the same configured server and authentication context rather than constructing +an unauthenticated request separately. + +See the [full API reference](https://opencode.mintlify.site/api) for available +endpoints, parameters, request bodies, and response schemas. The +raw [OpenAPI specification](https://opencode.mintlify.site/openapi.json) is also +available for code generation and other tooling. + +## Troubleshooting + +OpenCode runs a client and a background server. Start by determining whether a +problem belongs to the client, the shared server, or one project. + +- Check the service with `opencode2 service status` and verify the API with + `opencode2 api get /api/health`. +- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for + client startup and `role=server` for sessions, providers, plugins, + permissions, and tools. +- Run one reproduction with `OPENCODE_LOG_LEVEL=DEBUG` when normal logs are not + sufficient. +- Do not delete or edit the database, service registration, or service config + while diagnosing a problem. Back up persistent data before inspecting it + with external tools. +- Redact API keys, authorization headers, prompts, file contents, and other + sensitive data before sharing diagnostics. + +See the [full troubleshooting guide](https://opencode.mintlify.site/troubleshooting) +for service lifecycle commands, API inspection, log locations, explicit server +connections, issue-reporting details, and local development paths. diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 4c4e71be17..5b29d643ab 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -1,19 +1,43 @@ export * as PluginSupervisor from "./supervisor" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" +import { Context, Deferred, Effect, Layer, Option, Schema, Semaphore, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" import { Config } from "../config" import { ConfigPlugin } from "../config/plugin" +import { makeLocationNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { EventV2 } from "../event" +import { FileMutation } from "../file-mutation" +import { FileSystem } from "../filesystem" +import { Form } from "../form" import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Image } from "../image" +import { Integration } from "../integration" import { Location } from "../location" +import { LocationMutation } from "../location-mutation" +import { ModelsDev } from "../models-dev" import { Npm } from "../npm" +import { PermissionV2 } from "../permission" import { PluginV2 } from "../plugin" import { PluginPromise } from "../plugin/promise" +import { Reference } from "../reference" +import { Ripgrep } from "../ripgrep" +import { SessionInstructions } from "../session/instructions" +import { SessionTodo } from "../session/todo" +import { Shell } from "../shell" +import { SkillV2 } from "../skill" +import { ReadToolFileSystem } from "../tool/read-filesystem" +import { ToolRegistry } from "../tool/registry" +import { WebSearchTool } from "../tool/websearch" import { PluginInternal } from "./internal" +import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" const PluginModule = Schema.Struct({ @@ -246,7 +270,8 @@ const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interfa }) export interface Interface { - readonly ready: Effect.Effect + /** Wait for the initial plugin generation and startup updates to settle. */ + readonly flush: Effect.Effect } export class Service extends Context.Service()("@opencode/PluginSupervisor") {} @@ -259,35 +284,96 @@ const layer = Layer.effect( const config = yield* Config.Service const events = yield* EventV2.Service const lock = Semaphore.makeUnsafe(1) - const reload = Effect.fn("PluginSupervisor.reload")(() => - lock.withPermit( + const ready = yield* Deferred.make() + let observed = 0 + let applied = -1 + + const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) { + yield* lock.withPermit( Effect.gen(function* () { + if (applied >= target) return // 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()] - // Read the current layered config before resolving plugin directives and packages. - const entries = yield* config.entries() - const operations = yield* scan(entries) + 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) // Replace the active generation in one scoped, batched activation. yield* registry.activate(plugins) + applied = target }), - ), - ) - yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe( - Stream.runForEach(() => - reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + ) + }) + const updates = yield* events + .subscribe([Event.Updated, SdkPlugins.Updated]) + .pipe(Stream.toQueue({ capacity: 1, strategy: "sliding" })) + const signals = yield* Stream.concat( + Stream.succeed(0), + Stream.fromQueue(updates).pipe(Stream.mapEffect(() => Effect.sync(() => ++observed))), + ).pipe(Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 })) + const attempt = (target: number) => + activate(target).pipe( + Effect.map(() => observed === target), + Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))), + ) + + yield* signals.pipe( + Stream.runForEach((target) => + activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), ), Effect.forkScoped({ startImmediately: true }), ) - const fiber = yield* reload().pipe( - Effect.withSpan("PluginSupervisor.boot"), + yield* signals.pipe( + Stream.debounce("100 millis"), + Stream.mapEffect(attempt), + Stream.filter((settled) => settled), + Stream.take(1), + Stream.runDrain, + Effect.andThen(Deferred.succeed(ready, undefined)), Effect.forkScoped({ startImmediately: true }), ) - return Service.of({ ready: Fiber.join(fiber) }) + return Service.of({ flush: Deferred.await(ready) }) }), ) +const nodeLayer = layer as Layer.Layer + +export const node = makeLocationNode({ + service: Service, + layer: nodeLayer, + deps: [ + PluginV2.node, + SdkPlugins.node, + AgentV2.node, + Catalog.node, + CommandV2.node, + Config.node, + EventV2.node, + FileMutation.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + PermissionV2.node, + PluginRuntime.node, + Form.node, + ReadToolFileSystem.node, + Reference.node, + Ripgrep.node, + SessionInstructions.node, + SessionTodo.node, + Shell.node, + SkillV2.node, + ToolRegistry.toolsNode, + WebSearchTool.configNode, + ], +}) + export { layer } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b26b072dcf..ad53940971 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect" +import { Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" @@ -19,6 +19,7 @@ import { SessionSchema } from "./session/schema" import { AbsolutePath, PositiveInt, RelativePath } from "./schema" import { AgentV2 } from "./agent" import { SessionV1 } from "./v1/session" +import { Money } from "@opencode-ai/schema/money" import { InstallationVersion } from "./installation/version" import { Slug } from "./util/slug" import { ProjectTable } from "./project/sql" @@ -33,9 +34,8 @@ import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" import { Snapshot } from "./snapshot" -import { SessionCompaction } from "./session/compaction" import { SessionRevert } from "./session/revert" -import { Revert } from "@opencode-ai/schema/revert" +import { Session } from "@opencode-ai/schema/session" import { FSUtil } from "./fs-util" import { Mime } from "./mime" import type { EventLog } from "@opencode-ai/schema/event-log" @@ -47,8 +47,8 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" import { fileURLToPath } from "url" -export const RevertState = Revert.State -export type RevertState = Revert.State +export const RevertState = Session.Revert +export type RevertState = Session.Revert // get project -> project.locations // @@ -96,6 +96,7 @@ type CreateInput = CreateBaseInput & ({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never }) type CompactInput = { + id?: SessionMessage.ID sessionID: SessionSchema.ID } @@ -125,11 +126,18 @@ export class AttachmentError extends Schema.TaggedErrorClass()( uri: Schema.String, message: Schema.String, }) {} +export class CompactionConflictError extends Schema.TaggedErrorClass()( + "Session.CompactionConflictError", + { + sessionID: SessionSchema.ID, + inputID: SessionMessage.ID, + }, +) {} export class BusyError extends Schema.TaggedErrorClass()("Session.BusyError", { sessionID: SessionSchema.ID, }) {} export class SkillNotFoundError extends Schema.TaggedErrorClass()("Session.SkillNotFoundError", { - skill: Schema.String, + skill: SkillV2.ID, }) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -140,6 +148,7 @@ export type Error = | OperationUnavailableError | PromptConflictError | AttachmentError + | CompactionConflictError | BusyError | SkillNotFoundError | CommandV2.NotFoundError @@ -153,6 +162,7 @@ export interface Interface { readonly create: (input: CreateInput) => Effect.Effect readonly fork: (input: ForkInput) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect + readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { sessionID: SessionSchema.ID limit?: number @@ -161,14 +171,14 @@ export interface Interface { id: SessionMessage.ID direction: "previous" | "next" } - }) => Effect.Effect + }) => Effect.Effect readonly message: (input: { sessionID: SessionSchema.ID messageID: SessionMessage.ID - }) => Effect.Effect + }) => Effect.Effect readonly context: ( sessionID: SessionSchema.ID, - ) => Effect.Effect + ) => Effect.Effect /** * Durable, ordered, gap-free session log read. Replays public durable * session events after the exclusive `after` cursor, emits a `Synced` @@ -182,7 +192,10 @@ export interface Interface { after?: number follow?: boolean }) => Stream.Stream - readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect + readonly switchAgent: (input: { + sessionID: SessionSchema.ID + agent: AgentV2.ID + }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -200,7 +213,7 @@ export interface Interface { sessionID: SessionSchema.ID command: string arguments?: string - agent?: string + agent?: AgentV2.ID model?: ModelV2.Ref files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] @@ -218,12 +231,12 @@ export interface Interface { readonly skill: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - skill: string + skill: SkillV2.ID resume?: boolean }) => Effect.Effect readonly compact: ( input: CompactInput, - ) => Effect.Effect + ) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect readonly active: Effect.Effect> readonly background: (sessionID: SessionSchema.ID) => Effect.Effect @@ -234,13 +247,14 @@ export interface Interface { text: string description?: string metadata?: Record + resume?: boolean }) => Effect.Effect readonly revert: { readonly stage: (input: { sessionID: SessionSchema.ID messageID: SessionMessage.ID files?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect } @@ -263,7 +277,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const activeShells = new Set() const shellLocks = KeyedMutex.makeUnsafe() - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( @@ -312,7 +326,7 @@ const layer = Layer.effect( variant: input.model.variant, } : undefined, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: now, updated: now }, }) @@ -363,6 +377,15 @@ const layer = Layer.effect( if (!session) return yield* new NotFoundError({ sessionID }) return session }), + remove: Effect.fn("V2Session.remove")(function* (sessionID) { + yield* result.get(sessionID) + yield* execution.interrupt(sessionID) + yield* execution.awaitIdle(sessionID) + const children = yield* result.list({ parentID: sessionID }) + yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true }) + yield* events.publish(SessionEvent.Deleted, { sessionID }) + yield* events.remove(sessionID) + }), list: Effect.fn("V2Session.list")(function* (input = {}) { const direction = input.anchor?.direction ?? "next" const requestedOrder = input.order ?? "desc" @@ -510,7 +533,7 @@ const layer = Layer.effect( }) const model = command.model ?? commandAgent?.model ?? input.model if (agent !== undefined && session.agent !== AgentV2.ID.make(agent)) - yield* result.switchAgent({ sessionID: input.sessionID, agent }) + yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) }) if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) return yield* result.prompt({ @@ -529,7 +552,7 @@ const layer = Layer.effect( if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) const started = yield* Effect.gen(function* () { const shell = yield* Shell.Service - return yield* shell.create({ command: input.command, cwd: session.location.directory }) + return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 }) }).pipe(Effect.provide(locations.get(session.location))) yield* events.publish( SessionEvent.Shell.Started, @@ -572,12 +595,13 @@ const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* (input) { const session = yield* result.get(input.sessionID) const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location))) - const skill = (yield* skills.list()).find((item) => item.name === input.skill) + const skill = (yield* skills.list()).find((item) => item.id === input.skill) if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) yield* events.publish( SessionEvent.Skill.Activated, { sessionID: input.sessionID, + id: skill.id, name: skill.name, text: skill.content, }, @@ -616,19 +640,20 @@ const layer = Layer.effect( }) }), compact: Effect.fn("V2Session.compact")(function* (input) { - const session = yield* result.get(input.sessionID) - // TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions. - if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID }) - const context = yield* store.context(input.sessionID) - const compacted = yield* Effect.gen(function* () { - const compaction = yield* SessionCompaction.Service - return yield* compaction.compactManual({ session, messages: context }) + yield* result.get(input.sessionID) + const inputID = input.id ?? SessionMessage.ID.create() + const admitted = yield* SessionInput.admitCompaction(db, events, { + id: inputID, + sessionID: input.sessionID, }).pipe( - Effect.provide(locations.get(session.location)), - Effect.catch(() => Effect.succeed(false)), + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new CompactionConflictError({ sessionID: input.sessionID, inputID }) + : Effect.die(defect), + ), ) - if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" }) - return undefined + yield* execution.wake(input.sessionID) + return admitted }), wait: Effect.fn("V2Session.wait")(function* (sessionID) { yield* result.get(sessionID) @@ -663,6 +688,7 @@ const layer = Layer.effect( description: input.description, metadata: input.metadata, }) + if (input.resume === false) return yield* execution .resume(input.sessionID) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) @@ -724,11 +750,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { const fs = yield* FSUtil.Service const files = input.files - ? yield* Effect.forEach( - input.files, - (file) => materializeAttachment(fs, file), - { concurrency: 8 }, - ) + ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 }) : undefined return Prompt.make({ text: input.text, agents: input.agents, files }) }) @@ -746,6 +768,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct start: undefined, end: undefined, name: undefined, + mime: undefined, } : yield* readFileAttachment(fs, input.uri) if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES) @@ -754,11 +777,15 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`, }) - const mime = Mime.detect(resolved.bytes) + const mime = resolved.mime ?? Mime.detect(resolved.bytes) const content = mime === "text/plain" && resolved.start !== undefined ? Buffer.from( - Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"), + Buffer.from(resolved.bytes) + .toString("utf8") + .split("\n") + .slice(resolved.start - 1, resolved.end) + .join("\n"), ) : resolved.bytes return FileAttachment.create({ @@ -788,19 +815,38 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* ( }, catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }), }) - const info = yield* fs.stat(target).pipe( - Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), - ) + const info = yield* fs + .stat(target) + .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` }))) + if (info.type === "Directory") { + const entries = yield* fs + .readDirectoryEntries(target) + .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` }))) + return { + bytes: Buffer.from( + entries + .filter((entry) => entry.type === "file" || entry.type === "directory") + .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1)) + .map((entry) => entry.name + (entry.type === "directory" ? path.sep : "")) + .join("\n"), + ), + source: { type: "uri" as const, uri }, + start: undefined, + end: undefined, + name: path.basename(target), + mime: "application/x-directory", + } + } if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` }) if (Number(info.size) > MAX_ATTACHMENT_BYTES) return yield* new AttachmentError({ uri, message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`, }) - const bytes = yield* fs.readFile(target).pipe( - Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), - ) - return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) } + const bytes = yield* fs + .readFile(target) + .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` }))) + return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined } }) function decodeDataURL(uri: string) { diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 375d6e5266..ab5e41e405 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -25,20 +25,27 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside Rules: - Keep every section, even when empty. - Use terse bullets, not prose paragraphs. - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known. -- Put relevant files and symbols inside the section where they matter; do not add extra sections. - Do not mention the summary process or that context was compacted.` type Settings = { @@ -57,19 +64,21 @@ type Dependencies = { export type AutoInput = { readonly sessionID: SessionSchema.ID - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] readonly request: LLMRequest } type CompactInput = { readonly sessionID: SessionSchema.ID - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] readonly model: Model + readonly inputID?: SessionMessage.ID } export type ManualInput = { readonly session: SessionSchema.Info - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] + readonly inputID: SessionMessage.ID } export interface Interface { @@ -92,7 +101,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[ ) .join("\n") -const serialize = (message: SessionMessage.Message) => { +const serialize = (message: SessionMessage.Info) => { if (message.type === "user") { const files = message.files?.map( @@ -121,7 +130,7 @@ const serialize = (message: SessionMessage.Message) => { if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` - if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}` + if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}` return "" } @@ -140,7 +149,7 @@ const settings = (documents: readonly Config.Entry[]) => { } const select = ( - messages: readonly SessionMessage.Message[], + messages: readonly SessionMessage.Info[], tokens: number, ): { readonly head: string; readonly recent: string } | undefined => { const conversation = messages @@ -191,6 +200,7 @@ const make = (dependencies: Dependencies) => { readonly context: readonly string[] readonly recent: string readonly output?: number + readonly inputID?: SessionMessage.ID }) { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false @@ -201,6 +211,8 @@ const make = (dependencies: Dependencies) => { yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, reason: input.reason, + recent: input.recent, + inputID: input.inputID, }) const chunks: string[] = [] @@ -217,14 +229,38 @@ const make = (dependencies: Dependencies) => { .pipe( Stream.runForEach((event) => { if (LLMEvent.is.providerError(event)) failed = true - if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + if (LLMEvent.is.textDelta(event)) { + chunks.push(event.text) + return dependencies.events.publish(SessionEvent.Compaction.Delta, { + sessionID: input.sessionID, + text: event.text, + }) + } return Effect.void }), Effect.as(true), Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.onInterrupt(() => + input.reason === "auto" + ? dependencies.events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.sessionID, + reason: input.reason, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + inputID: input.inputID, + }) + : Effect.void, + ), ) const summary = chunks.join("") - if (!summarized || failed || !summary.trim()) return false + if (!summarized || failed || !summary.trim()) { + yield* dependencies.events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.sessionID, + reason: input.reason, + error: { type: "compaction.failed", message: "Compaction produced no summary" }, + inputID: input.inputID, + }) + return false + } yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { sessionID: input.sessionID, reason: input.reason, @@ -254,7 +290,9 @@ const make = (dependencies: Dependencies) => { if (context === undefined || context <= 0) return false const selected = select(input.messages, config.tokens) if (!selected) return false - const previousSummary = input.messages.find((message) => message.type === "compaction") + const previousSummary = input.messages.find( + (message) => message.type === "compaction" && message.status === "completed", + ) const hasHead = selected.head.length > 0 if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false const forcedShortContext = input.force && !hasHead @@ -269,6 +307,7 @@ const make = (dependencies: Dependencies) => { ), recent: forcedShortContext ? "" : selected.recent, output: input.output, + inputID: input.inputID, }) }) const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) { @@ -312,6 +351,7 @@ export const layer = Layer.effect( sessionID: input.session.id, messages: input.messages, model: resolved.model, + inputID: input.inputID, }) }), }) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 68f4fd32a4..3de8af3a56 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -1,6 +1,8 @@ import { Schema } from "effect" +import { Agent } from "@opencode-ai/schema/agent" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" +import { SessionError } from "@opencode-ai/schema/session-error" export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, @@ -10,3 +12,29 @@ export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.AgentNotFoundError", { + sessionID: SessionSchema.ID, + agent: Agent.ID, +}) { + override get message() { + return `Agent not found: "${this.agent}"` + } +} + +export class StepFailedError extends Schema.TaggedErrorClass()("Session.StepFailedError", { + error: SessionError.Error, +}) { + override get message() { + return this.error.message + } +} + +export class UserInterruptedError extends Schema.TaggedErrorClass()( + "Session.UserInterruptedError", + {}, +) { + override get message() { + return "Session interrupted by user" + } +} diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 4e68e6c3d9..c925ee655c 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,4 +1,4 @@ -import { Cause, DateTime, Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { EventV2 } from "../../event" import { LocationServiceMap } from "../../location-service-map" import { makeGlobalNode } from "../../effect/app-node" @@ -8,6 +8,16 @@ import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionExecution } from "../execution" +import { toSessionError } from "../to-session-error" +import { UserInterruptedError } from "../error" + +export function terminal(exit: Exit.Exit, reason?: "user" | "shutdown" | "superseded") { + if (Exit.isSuccess(exit)) return { type: "succeeded" as const } + if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" } + const failure = Cause.squash(exit.cause) + if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const } + return { type: "failed" as const, error: toSessionError(failure) } +} /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ const layer = Layer.effect( @@ -16,7 +26,23 @@ const layer = Layer.effect( const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service const events = yield* EventV2.Service - const coordinator = yield* SessionRunCoordinator.make({ + const reportLifecycle = (sessionID: SessionSchema.ID, effect: Effect.Effect) => + effect.pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to publish Session execution lifecycle", cause).pipe( + Effect.annotateLogs({ sessionID }), + ), + ), + Effect.asVoid, + ) + const coordinator = yield* SessionRunCoordinator.make< + SessionSchema.ID, + SessionRunner.RunError, + "user" | "shutdown" | "superseded" + >({ + started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })), drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) @@ -29,28 +55,31 @@ const layer = Layer.effect( ), ) }), - // One ExecutionSettled per execution (busy period), covering every coalesced drain. - settled: (sessionID, exit) => - Effect.gen(function* () { - const failure = - Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined - yield* events.publish(SessionEvent.ExecutionSettled, { - sessionID, - outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", - error: - failure !== undefined - ? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) } - : undefined, - }) - }).pipe( - Effect.catchCause(() => Effect.void), - Effect.asVoid, + // One terminal observation per busy period, covering every coalesced drain. + settled: (sessionID, exit, reason) => + reportLifecycle( + sessionID, + Effect.gen(function* () { + const outcome = terminal(exit, reason) + if (outcome.type === "succeeded") { + yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID }) + return + } + if (outcome.type === "interrupted") { + yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason }) + return + } + yield* events.publish(SessionEvent.Execution.Failed, { + sessionID, + error: outcome.error, + }) + }), ), }) return SessionExecution.Service.of({ active: coordinator.active, - interrupt: coordinator.interrupt, + interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"), resume: coordinator.run, wake: coordinator.wake, awaitIdle: coordinator.awaitIdle, diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index b3f0b6c741..f2e3122c96 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm" import { Effect, Schema } from "effect" import { Database } from "../database/database" import { MessageDecodeError } from "./error" @@ -8,13 +8,19 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -const decode = Schema.decodeUnknownEffect(SessionMessage.Message) +const decode = Schema.decodeUnknownEffect(SessionMessage.Info) export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, "compaction"), + sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`, + ), + ) .orderBy(desc(SessionMessageTable.seq)) .limit(1) .get() diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 66832750fd..2c507d0058 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -1,4 +1,4 @@ -import { DateTime } from "effect" +import { DateTime, Schema } from "effect" import { AgentV2 } from "../agent" import { Location } from "../location" import { ModelV2 } from "../model" @@ -9,7 +9,10 @@ import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" import { SessionMessage } from "./message" -import { Snapshot } from "../snapshot" +import { PersistedRevert } from "@opencode-ai/schema/session-revert" +import { Money } from "@opencode-ai/schema/money" + +const decodeRevert = Schema.decodeUnknownSync(PersistedRevert) export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ @@ -17,6 +20,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In projectID: ProjectV2.ID.make(row.project_id), title: row.title, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, + fork: row.fork_session_id + ? { + sessionID: SessionSchema.ID.make(row.fork_session_id), + messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined, + } + : undefined, agent: row.agent ? AgentV2.ID.make(row.agent) : undefined, model: row.model ? { @@ -25,7 +34,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In variant: ModelV2.VariantID.make(row.model.variant ?? "default"), } : undefined, - cost: row.cost, + cost: Money.USD.make(row.cost), tokens: { input: row.tokens_input, output: row.tokens_output, @@ -40,7 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, }), subpath: row.path ? RelativePath.make(row.path) : undefined, - revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined, + revert: row.revert ? decodeRevert(row.revert) : undefined, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 72622d32ef..41eb36ea5b 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,9 +2,10 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { Admitted, Delivery } from "@opencode-ai/schema/session-input" +import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" +import { KeyedMutex } from "../effect/keyed-mutex" import { SessionEvent } from "./event" import { SessionMessage } from "./message" import { Prompt } from "@opencode-ai/schema/prompt" @@ -13,30 +14,77 @@ import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export { Admitted, Delivery } +export { Admitted, Compaction, Delivery, Info, PromptEntry } const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) +const inboxLocks = KeyedMutex.makeUnsafe() -const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => - Admitted.make({ +export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { + id: SessionMessage.ID, +}) {} + +const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => { + const base = { admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), + timeCreated: DateTime.makeUnsafe(row.time_created), + } + if (row.type === "compaction") + return Compaction.make({ + ...base, + type: "compaction", + ...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }), + }) + if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id }) + return PromptEntry.make({ + ...base, + type: "prompt", prompt: decodePrompt(row.prompt), delivery: row.delivery, - timeCreated: DateTime.makeUnsafe(row.time_created), ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), }) +} + +const toAdmitted = (entry: PromptEntry): Admitted => + Admitted.make({ + admittedSeq: entry.admittedSeq, + id: entry.id, + sessionID: entry.sessionID, + prompt: entry.prompt, + delivery: entry.delivery, + timeCreated: entry.timeCreated, + ...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }), + }) export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) { const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie) return row === undefined ? undefined : fromRow(row) }) -export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { - id: SessionMessage.ID, -}) {} +export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + const row = yield* db + .select() + .from(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, sessionID), + eq(SessionInputTable.type, "compaction"), + isNull(SessionInputTable.promoted_seq), + ), + ) + .orderBy(asc(SessionInputTable.admitted_seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (!row) return + const entry = fromRow(row) + return entry.type === "compaction" ? entry : undefined +}) export const admit = Effect.fn("SessionInput.admit")(function* ( db: DatabaseService, @@ -49,7 +97,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( }, ) { const existing = yield* find(db, input.id) - if (existing !== undefined) return existing + if (existing !== undefined) { + if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return toAdmitted(existing) + } return yield* events .publish(SessionEvent.PromptAdmitted, { inputID: input.id, @@ -73,11 +124,54 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ), ), Effect.catchDefect((defect) => - find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))), + find(db, input.id).pipe( + Effect.flatMap((stored) => + stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect), + ), + ), ), ) }) +export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* ( + db: DatabaseService, + events: EventV2.Interface, + input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }, +) { + return yield* inboxLocks.withLock(input.sessionID)( + Effect.gen(function* () { + const exact = yield* find(db, input.id) + if (exact) { + if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + } + const pending = yield* pendingCompaction(db, input.sessionID) + if (pending) return pending + return yield* events + .publish(SessionEvent.Compaction.Admitted, { + inputID: input.id, + sessionID: input.sessionID, + }) + .pipe( + Effect.flatMap((event) => { + if (event.durable === undefined) + return Effect.die(new Error("Compaction admission event is missing aggregate sequence")) + return pendingCompaction(db, input.sessionID).pipe( + Effect.flatMap((stored) => + stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })), + ), + ) + }), + Effect.catchDefect((defect) => + pendingCompaction(db, input.sessionID).pipe( + Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))), + ), + ), + ) + }), + ) +}) + export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, input: { @@ -101,6 +195,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio .values({ id: input.id, session_id: input.sessionID, + type: "prompt", admitted_seq: input.admittedSeq, prompt: encodePrompt(input.prompt), delivery: input.delivery, @@ -113,6 +208,44 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) +export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* ( + db: DatabaseService, + input: { + readonly admittedSeq: number + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + readonly timeCreated: DateTime.Utc + }, +) { + const message = yield* db + .select({ id: SessionMessageTable.id }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, input.id)) + .get() + .pipe(Effect.orDie) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + const stored = yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + type: "compaction", + admitted_seq: input.admittedSeq, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .onConflictDoNothing() + .returning() + .get() + .pipe(Effect.orDie) + if (stored) { + const entry = fromRow(stored) + return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id })) + } + const pending = yield* pendingCompaction(db, input.sessionID) + if (pending) return pending + return yield* Effect.die(new LifecycleConflict({ id: input.id })) +}) + export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( db: DatabaseService, input: { @@ -121,6 +254,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot readonly promotedSeq: number }, ) { + if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) const updated = yield* db .update(SessionInputTable) .set({ promoted_seq: input.promotedSeq }) @@ -128,6 +262,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot and( eq(SessionInputTable.id, input.id), eq(SessionInputTable.session_id, input.sessionID), + eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), ), ) @@ -136,29 +271,58 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot .pipe(Effect.orDie) if (updated) { const stored = fromRow(updated) - if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (stored.type !== "prompt" || stored.sessionID !== input.sessionID) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) return stored } - - // Every PromptPromoted event is published from an admitted inbox row, so a missing or - // divergent row on replay is an invariant violation. const stored = yield* find(db, input.id) - if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq) + if ( + !stored || + stored.type !== "prompt" || + stored.sessionID !== input.sessionID || + stored.promotedSeq !== input.promotedSeq + ) return yield* Effect.die(new LifecycleConflict({ id: input.id })) return stored }) +export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* ( + db: DatabaseService, + input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number }, +) { + const updated = yield* db + .update(SessionInputTable) + .set({ promoted_seq: input.handledSeq }) + .where( + and( + eq(SessionInputTable.session_id, input.sessionID), + eq(SessionInputTable.type, "compaction"), + isNull(SessionInputTable.promoted_seq), + ), + ) + .returning() + .get() + .pipe(Effect.orDie) + if (updated) { + const stored = fromRow(updated) + return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id })) + } + return undefined +}) + export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, delivery: Delivery, ) { + if (yield* pendingCompaction(db, sessionID)) return false const row = yield* db .select({ id: SessionInputTable.id }) .from(SessionInputTable) .where( and( eq(SessionInputTable.session_id, sessionID), + eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, delivery), ), @@ -181,42 +345,44 @@ export const equivalent = ( input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) -const matchesProjection = ( - input: Admitted, - expected: { - readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - readonly timeCreated: DateTime.Utc - }, -) => - equivalent(input, expected) && - DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated) - const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, events: EventV2.Interface, sessionID: SessionSchema.ID, rows: ReadonlyArray, ) { - for (const row of rows) { - const id = SessionMessage.ID.make(row.id) - yield* events - .publish(SessionEvent.PromptPromoted, { - sessionID, - inputID: id, - }) - .pipe( - Effect.catchDefect((defect) => - defect instanceof LifecycleConflict - ? find(db, id).pipe( - Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), - ) - : Effect.die(defect), - ), + return yield* inboxLocks.withLock(sessionID)( + Effect.gen(function* () { + if (yield* pendingCompaction(db, sessionID)) return 0 + yield* Effect.forEach( + rows, + (row) => { + const entry = fromRow(row) + if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id })) + return events + .publish(SessionEvent.PromptPromoted, { + sessionID, + inputID: entry.id, + }) + .pipe( + Effect.catchDefect((defect) => + defect instanceof LifecycleConflict + ? find(db, entry.id).pipe( + Effect.flatMap((stored) => + stored?.type === "prompt" && stored.promotedSeq !== undefined + ? Effect.void + : Effect.die(defect), + ), + ) + : Effect.die(defect), + ), + ) + }, + { discard: true }, ) - } - return rows.length + return rows.length + }), + ) }) export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( @@ -224,12 +390,14 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( events: EventV2.Interface, sessionID: SessionSchema.ID, ) { + if (yield* pendingCompaction(db, sessionID)) return 0 const rows = yield* db .select() .from(SessionInputTable) .where( and( eq(SessionInputTable.session_id, sessionID), + eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "steer"), ), @@ -245,12 +413,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun events: EventV2.Interface, sessionID: SessionSchema.ID, ) { + if (yield* pendingCompaction(db, sessionID)) return false const row = yield* db .select() .from(SessionInputTable) .where( and( eq(SessionInputTable.session_id, sessionID), + eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "queue"), ), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 4bf4e43825..01e045ce5f 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -1,10 +1,10 @@ import { castDraft, produce, type WritableDraft } from "immer" -import { Effect } from "effect" +import { DateTime, Effect } from "effect" import { SessionEvent } from "./event" import { SessionMessage } from "./message" export type MemoryState = { - messages: SessionMessage.Message[] + messages: SessionMessage.Info[] } export interface Adapter { @@ -14,11 +14,13 @@ export interface Adapter { messageID: SessionMessage.ID, ) => Effect.Effect readonly getShell: ( - shellID: SessionMessage.Shell["shell"]["id"], + shellID: SessionMessage.Shell["shellID"], ) => Effect.Effect + readonly getCompaction: () => Effect.Effect readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect - readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect + readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect + readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect } export function memory(state: MemoryState): Adapter { @@ -26,6 +28,8 @@ export function memory(state: MemoryState): Adapter { state.messages.findLastIndex((message) => message.id === messageID) const shellIndex = (messageID: SessionMessage.ID) => state.messages.findLastIndex((message) => message.id === messageID) + const compactionIndex = () => + state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running") // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") @@ -58,10 +62,17 @@ export function memory(state: MemoryState): Adapter { getShell(shellID) { return Effect.sync(() => { return state.messages.find((message): message is SessionMessage.Shell => { - return message.type === "shell" && message.shell.id === shellID + return message.type === "shell" && message.shellID === shellID }) }) }, + getCompaction() { + return Effect.sync(() => { + const index = compactionIndex() + const message = state.messages[index] + return message?.type === "compaction" ? message : undefined + }) + }, updateAssistant(assistant) { return Effect.sync(() => { const index = assistantIndex(assistant.id) @@ -80,6 +91,12 @@ export function memory(state: MemoryState): Adapter { state.messages[index] = shell }) }, + updateCompaction(compaction) { + return Effect.sync(() => { + const index = state.messages.findLastIndex((message) => message.id === compaction.id) + if (index >= 0) state.messages[index] = compaction + }) + }, appendMessage(message) { return Effect.sync(() => { state.messages.push(message) @@ -99,11 +116,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), ) - const latestText = (assistant: DraftAssistant | undefined, textID: string) => - assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID) + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") - const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) + const latestReasoning = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed) const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) => Effect.gen(function* () { @@ -111,8 +128,20 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe)) }) + const clearCurrentRetry = Effect.gen(function* () { + const assistant = yield* adapter.getCurrentAssistant() + if (assistant?.retry) { + yield* adapter.updateAssistant( + produce(assistant, (draft) => { + draft.retry = undefined + }), + ) + } + }) + return Effect.gen(function* () { yield* SessionEvent.All.match(event, { + "session.usage.updated": () => Effect.void, "session.agent.selected": (event) => { return adapter.appendMessage( SessionMessage.AgentSelected.make({ @@ -141,24 +170,27 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.moved": () => Effect.void, "session.renamed": () => Effect.void, + "session.deleted": () => Effect.void, "session.forked": () => Effect.void, "session.prompt.promoted": () => Effect.void, "session.prompt.admitted": () => Effect.void, - "session.execution.settled": () => Effect.void, + "session.execution.started": () => Effect.void, + "session.execution.succeeded": () => clearCurrentRetry, + "session.execution.failed": () => clearCurrentRetry, + "session.execution.interrupted": () => clearCurrentRetry, "session.instructions.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ id: SessionMessage.ID.fromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }), ), - "session.instructions.discovered": () => Effect.void, "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, metadata: event.data.metadata, @@ -173,8 +205,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { SessionMessage.Skill.make({ id: SessionMessage.ID.fromEvent(event.id), type: "skill", + skill: event.data.id, name: event.data.name, text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }), ) @@ -185,7 +219,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: SessionMessage.ID.fromEvent(event.id), type: "shell", metadata: event.metadata, - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, time: { created: event.created }, }), ) @@ -196,7 +232,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { if (currentShell) { yield* adapter.updateShell( produce(currentShell, (draft) => { - draft.shell = castDraft(event.data.shell) + draft.status = event.data.shell.status + draft.exit = event.data.shell.exit draft.output = event.data.output draft.time.completed = event.created }), @@ -206,10 +243,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.step.started": (event) => { return Effect.gen(function* () { + const existing = yield* adapter.getAssistant(event.data.assistantMessageID) + if (existing) { + yield* adapter.updateAssistant( + produce(existing, (draft) => { + draft.agent = event.data.agent + draft.model = castDraft(event.data.model) + draft.retry = undefined + draft.error = undefined + draft.finish = undefined + draft.time.completed = undefined + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot } + }), + ) + return + } const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { + draft.retry = undefined draft.time.completed = event.created }), ) @@ -220,6 +273,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, time: { created: event.created }, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, @@ -245,25 +299,28 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.created draft.finish = "error" - draft.error = event.data.error + draft.error = castDraft(event.data.error) + draft.retry = undefined + if (event.data.cost !== undefined && event.data.tokens !== undefined) { + draft.cost = event.data.cost + draft.tokens = castDraft(event.data.tokens) + } }) }, "session.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - draft.content.push( - castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), - ) + draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" }))) }) }, "session.text.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestText(draft, event.data.textID) + const match = latestText(draft) if (match) match.text += event.data.delta }) }, "session.text.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestText(draft, event.data.textID) + const match = latestText(draft) if (match) match.text = event.data.text }) }, @@ -276,7 +333,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), + state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), }), ), ) @@ -286,14 +343,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.tool.input.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && match.state.status === "pending") match.state.input = event.data.text + if (match && match.state.status === "streaming") match.state.input = event.data.text }) }, "session.tool.called": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match) { - match.provider = event.data.provider + match.executed = event.data.executed + match.providerState = event.data.state match.time.ran = event.created match.state = castDraft( SessionMessage.ToolStateRunning.make({ @@ -319,11 +377,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateCompleted.make({ @@ -331,7 +386,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { input: match.state.input, structured: event.data.structured, content: [...event.data.content], - outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [], result: event.data.result, }), ) @@ -341,12 +395,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.tool.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && (match.state.status === "pending" || match.state.status === "running")) { - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + if (match && (match.state.status === "streaming" || match.state.status === "running")) { + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateError.make({ @@ -367,9 +418,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { castDraft( SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: event.data.reasoningID, text: "", - providerMetadata: event.data.providerMetadata, + state: event.data.state, time: { created: event.created }, }), ), @@ -378,36 +428,91 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.reasoning.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) + const match = latestReasoning(draft) if (match) match.text += event.data.delta }) }, "session.reasoning.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) + const match = latestReasoning(draft) if (match) { match.text = event.data.text match.time = { created: match.time?.created ?? event.created, completed: event.created } - if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata + if (event.data.state !== undefined) match.state = event.data.state } }) }, - "session.retried": () => Effect.void, - "session.compaction.started": () => Effect.void, - "session.compaction.delta": () => Effect.void, - "session.compaction.ended": (event) => { - return adapter.appendMessage( - SessionMessage.Compaction.make({ - id: SessionMessage.ID.fromEvent(event.id), + "session.retry.scheduled": (event) => { + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + draft.retry = { + attempt: event.data.attempt, + at: DateTime.makeUnsafe(event.data.at), + error: castDraft(event.data.error), + } + }) + }, + "session.compaction.admitted": () => Effect.void, + "session.compaction.started": (event) => + adapter.appendMessage( + SessionMessage.CompactionRunning.make({ + id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id), type: "compaction", + status: "running", metadata: event.metadata, reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, + summary: "", + recent: event.data.recent ?? "", time: { created: event.created }, }), - ) + ), + "session.compaction.delta": (event) => + Effect.gen(function* () { + const current = yield* adapter.getCompaction() + if (current?.status !== "running") return + yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text }) + }), + "session.compaction.ended": (event) => { + return Effect.gen(function* () { + const current = yield* adapter.getCompaction() + if (current?.status === "running") { + yield* adapter.updateCompaction({ + ...current, + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + }) + return + } + yield* adapter.appendMessage( + SessionMessage.Compaction.make({ + id: SessionMessage.ID.fromEvent(event.id), + type: "compaction", + status: "completed", + metadata: event.metadata, + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + time: { created: event.created }, + }), + ) + }) }, + "session.compaction.failed": (event) => + Effect.gen(function* () { + const current = yield* adapter.getCompaction() + const failed = SessionMessage.CompactionFailed.make({ + id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id), + type: "compaction", + status: "failed", + metadata: current?.metadata ?? event.metadata, + reason: event.data.reason, + error: event.data.error, + time: current?.time ?? { created: event.created }, + }) + if (current?.status === "running") return yield* adapter.updateCompaction(failed) + yield* adapter.appendMessage(failed) + }), "session.revert.staged": () => Effect.void, "session.revert.cleared": () => Effect.void, "session.revert.committed": () => Effect.void, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index bbe9ce53b1..dcf79ff3a1 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,7 +1,7 @@ export * as SessionProjector from "./projector" import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeGlobalNode } from "../effect/app-node" @@ -24,12 +24,14 @@ import { } from "./sql" import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" +import { Money } from "@opencode-ai/schema/money" type DatabaseService = Database.Interface["db"] -type MessageEvent = Exclude +type CurrentDurableEvent = Extract +type MessageEvent = Exclude -const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) export class SessionAlreadyProjected extends Error {} @@ -45,11 +47,6 @@ type Usage = { const ForkBatchSize = 500 -const emptyUsage = (): Usage => ({ - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, -}) - const forkTitle = (value: string) => { const match = value.match(/^(.+) \(fork #(\d+)\)$/) if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` @@ -64,22 +61,6 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } } -function addUsage(target: Usage, value: Usage) { - target.cost += value.cost - target.tokens.input += value.tokens.input - target.tokens.output += value.tokens.output - target.tokens.reasoning += value.tokens.reasoning - target.tokens.cache.read += value.tokens.cache.read - target.tokens.cache.write += value.tokens.cache.write -} - -function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined { - if (row.type !== "assistant") return undefined - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined - return { cost: message.cost, tokens: message.tokens } -} - function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert { return { id: info.id, @@ -105,7 +86,14 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning, tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read, tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write, - revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null, + revert: info.revert + ? { + messageID: SessionMessage.ID.make(info.revert.messageID), + partID: info.revert.partID, + snapshot: info.revert.snapshot, + diff: info.revert.diff, + } + : null, permission: info.permission ? [...info.permission] : undefined, time_created: info.time.created, time_updated: info.time.updated, @@ -148,6 +136,37 @@ function applyUsage( .pipe(Effect.orDie) } +const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* ( + db: DatabaseService, + events: EventV2.Interface, + sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"], +) { + const row = yield* db + .select({ + cost: SessionTable.cost, + input: SessionTable.tokens_input, + output: SessionTable.tokens_output, + reasoning: SessionTable.tokens_reasoning, + cacheRead: SessionTable.tokens_cache_read, + cacheWrite: SessionTable.tokens_cache_write, + }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) return + yield* events.publish(SessionEvent.UsageUpdated, { + sessionID, + cost: Money.USD.make(row.cost), + tokens: { + input: row.input, + output: row.output, + reasoning: row.reasoning, + cache: { read: row.cacheRead, write: row.cacheWrite }, + }, + }) +}) + const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( db: DatabaseService, event: typeof SessionEvent.Forked.Type, @@ -184,13 +203,15 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .limit(1) .get() .pipe(Effect.orDie) - const copiedSeq = copied?.seq ?? 0 + const copiedSeq = copied?.seq const stored = yield* db .insert(SessionTable) .values({ id: event.data.sessionID, - parent_id: event.data.parentID, + parent_id: null, + fork_session_id: event.data.parentID, + fork_message_id: event.data.from, project_id: parent.project_id, workspace_id: parent.workspace_id, slug: Slug.create(), @@ -232,9 +253,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } - const usage = emptyUsage() let cursor = -1 - while (true) { + while (copiedSeq !== undefined) { const rows = yield* db .select() .from(SessionMessageTable) @@ -242,7 +262,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( and( eq(SessionMessageTable.session_id, event.data.parentID), gt(SessionMessageTable.seq, cursor), - copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), + lt(SessionMessageTable.seq, copiedSeq + 1), + sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`, ), ) .orderBy(asc(SessionMessageTable.seq)) @@ -265,7 +286,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( seq: row.seq, time_created: row.time_created, time_updated: row.time_updated, - data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data, + data: row.data, } }), ) @@ -292,11 +313,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .values( inputRows.flatMap((row) => { const id = idMap.get(row.id) - return id + return id && row.type === "prompt" ? [ { id, session_id: event.data.sessionID, + type: "prompt" as const, prompt: row.prompt, delivery: row.delivery, admitted_seq: row.admitted_seq, @@ -311,34 +333,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } - for (const row of rows) { - const value = messageUsage(row) - if (value) addUsage(usage, value) - } cursor = rows.at(-1)!.seq } - - yield* db - .update(SessionTable) - .set({ - cost: usage.cost, - tokens_input: usage.tokens.input, - tokens_output: usage.tokens.output, - tokens_reasoning: usage.tokens.reasoning, - tokens_cache_read: usage.tokens.cache.read, - tokens_cache_write: usage.tokens.cache.write, - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie) - if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) + if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) }) function run(db: DatabaseService, event: MessageEvent) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) - const updateMessage = (message: SessionMessage.Message) => { + const updateMessage = (message: SessionMessage.Info) => { if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) @@ -355,7 +359,7 @@ function run(db: DatabaseService, event: MessageEvent) { .run() .pipe(Effect.orDie) } - const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) + const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { getModel() { return db @@ -414,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) { and( eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"), - sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`, + sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`, ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -426,15 +430,37 @@ function run(db: DatabaseService, event: MessageEvent) { return message.type === "shell" ? message : undefined }) }, + getCompaction() { + return Effect.gen(function* () { + const row = yield* db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.sessionID), + eq(SessionMessageTable.type, "compaction"), + sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`, + ), + ) + .orderBy(desc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (!row) return + const message = decodeRow(row) + return message.type === "compaction" ? message : undefined + }) + }, updateAssistant: updateMessage, updateShell: updateMessage, + updateCompaction: updateMessage, appendMessage, } yield* SessionMessageUpdater.update(adapter, event) }) } -function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) { +function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) { if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded @@ -503,6 +529,9 @@ const layer = Layer.effectDiscard( yield* events.project(SessionV1.Event.Deleted, (event) => db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), ) + yield* events.project(SessionEvent.Deleted, (event) => + db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), + ) yield* events.project(SessionV1.Event.MessageUpdated, (event) => Effect.gen(function* () { const time_created = event.data.info.time.created @@ -634,22 +663,40 @@ const layer = Layer.effectDiscard( }) }), ) - yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) - yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) - yield* events.project(SessionEvent.Skill.Activated, (event) => - insertMessage(db, event, { - id: SessionMessage.ID.fromEvent(event.id), - type: "skill", - name: event.data.name, - text: event.data.text, - time: { created: event.created }, + yield* events.project(SessionEvent.Compaction.Admitted, (event) => + Effect.gen(function* () { + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) + yield* SessionInput.projectCompactionAdmitted(db, { + admittedSeq: event.durable.seq, + id: event.data.inputID, + sessionID: event.data.sessionID, + timeCreated: event.created, + }) }), ) + yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) + yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event)) + yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) + yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) + yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) + yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) - yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event)) - yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event)) + yield* events.project(SessionEvent.Step.Ended, (event) => + Effect.gen(function* () { + yield* run(db, event) + yield* applyUsage(db, event.data.sessionID, event.data) + }), + ) + yield* events.project(SessionEvent.Step.Failed, (event) => + Effect.gen(function* () { + yield* run(db, event) + if (event.data.cost !== undefined && event.data.tokens !== undefined) + yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens }) + }), + ) yield* events.project(SessionEvent.Text.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) @@ -660,18 +707,45 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) - // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) + yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event)) + yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event)) + yield* events.project(SessionEvent.Compaction.Ended, (event) => + Effect.gen(function* () { + yield* run(db, event) + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) + if (event.data.reason === "manual") + yield* SessionInput.settleCompaction(db, { + sessionID: event.data.sessionID, + handledSeq: event.durable.seq, + }) + }), + ) + yield* events.project(SessionEvent.Compaction.Failed, (event) => + Effect.gen(function* () { + yield* run(db, event) + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) + if (event.data.reason === "manual") + yield* SessionInput.settleCompaction(db, { + sessionID: event.data.sessionID, + handledSeq: event.durable.seq, + }) + }), + ) yield* events.project(SessionEvent.RevertEvent.Staged, (event) => - db - .update(SessionTable) - .set({ - revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, - time_updated: DateTime.toEpochMillis(event.created), - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie, Effect.asVoid), + Effect.gen(function* () { + const revert = event.data.revert + yield* db + .update(SessionTable) + .set({ + revert: { ...revert, files: revert.files ? [...revert.files] : undefined }, + time_updated: DateTime.toEpochMillis(event.created), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + }), ) yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => db @@ -687,14 +761,11 @@ const layer = Layer.effectDiscard( .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( - and( - eq(SessionMessageTable.session_id, event.data.sessionID), - eq(SessionMessageTable.id, event.data.messageID), - ), + and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)), ) .get() .pipe(Effect.orDie) - if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`)) + if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`)) yield* db .delete(SessionMessageTable) .where( @@ -721,6 +792,17 @@ const layer = Layer.effectDiscard( yield* InstructionCheckpoint.reset(db, event.data.sessionID) }), ) + yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe( + Stream.runForEach((event) => { + if ( + event.type === SessionEvent.Step.Failed.type && + (event.data.cost === undefined || event.data.tokens === undefined) + ) + return Effect.void + return publishSessionUsage(db, events, event.data.sessionID) + }), + Effect.forkScoped({ startImmediately: true }), + ) }), ) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 8c0b8bbf7b..42bc34e694 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -1,7 +1,7 @@ export * as SessionRevert from "./revert" import { and, asc, eq, gt } from "drizzle-orm" -import { DateTime, Effect, Schema } from "effect" +import { Effect, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { RelativePath } from "../schema" @@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) { .orderBy(asc(SessionMessageTable.seq)) .all() .pipe(Effect.orDie) - const decode = Schema.decodeUnknownEffect(SessionMessage.Message) + const decode = Schema.decodeUnknownEffect(SessionMessage.Info) const files = new Map() for (const row of rows) { const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie) @@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID }) const restore = new Map() if (original) { - for (const file of input.session.revert?.files ?? []) restore.set(file.path, original) + for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original) } if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree) if (restore.size) yield* snapshot.restore({ files: restore }) @@ -81,10 +81,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { const revert = { messageID: input.messageID, snapshot: original, - diff: files - .map((file) => file.patch) - .join("") - .trim(), files, } satisfies SessionSchema.Info["revert"] yield* events.publish(SessionEvent.RevertEvent.Staged, { @@ -100,7 +96,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined if (original) yield* snapshot.restore({ - files: new Map((session.revert.files ?? []).map((file) => [file.path, original])), + files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])), }) const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Cleared, { @@ -113,6 +109,6 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID: session.id, - messageID: session.revert.messageID, + to: session.revert.messageID, }) }) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index 8524b1c1f8..1550280164 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -3,7 +3,7 @@ export * as SessionRunCoordinator from "./run-coordinator" import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" /** Serializes execution for each key while allowing different keys to run concurrently. */ -export interface Coordinator { +export interface Coordinator { /** Snapshots keys with an execution owned by this coordinator. */ readonly active: Effect.Effect> /** Starts an execution while idle, or joins the active execution and returns its exit. */ @@ -11,7 +11,7 @@ export interface Coordinator { /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ readonly wake: (key: Key) => Effect.Effect /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ - readonly interrupt: (key: Key) => Effect.Effect + readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ readonly awaitIdle: (key: Key) => Effect.Effect } @@ -23,11 +23,13 @@ export interface Coordinator { * closes the gap between a drain's last eligibility check and the idle transition, since * those cannot be one atomic step. `done` resolves joiners with this execution's exit. */ -type Execution = { +type Execution = { readonly done: Deferred.Deferred owner?: Fiber.Fiber pendingWake: boolean stopping: boolean + settling: boolean + interruptionReason?: Reason } /** @@ -41,19 +43,21 @@ type Execution = { * waiters get this exit * ``` */ -export const make = (options: { +export const make = (options: { readonly drain: (key: Key, force: boolean) => Effect.Effect + /** Runs once when a process-local busy period begins, before its first drain. */ + readonly started?: (key: Key) => Effect.Effect /** * Runs in the execution fiber for every exit, including interruption, after the final * drain and before the execution settles (waiters resolve after it completes). */ - readonly settled?: (key: Key, exit: Exit.Exit) => Effect.Effect -}): Effect.Effect, never, Scope.Scope> => + readonly settled?: (key: Key, exit: Exit.Exit, reason?: Reason) => Effect.Effect +}): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const executions = new Map>() + const executions = new Map>() const fork = yield* FiberSet.makeRuntime() - const loop = (key: Key, execution: Execution, force: boolean): Effect.Effect => + const loop = (key: Key, execution: Execution, force: boolean): Effect.Effect => Effect.suspend(() => options.drain(key, force)).pipe( Effect.flatMap(() => Effect.suspend(() => { @@ -66,15 +70,25 @@ export const make = (options: { ) const start = (key: Key, force: boolean) => { - const execution: Execution = { done: Deferred.makeUnsafe(), pendingWake: false, stopping: false } + const execution: Execution = { + done: Deferred.makeUnsafe(), + pendingWake: false, + stopping: false, + settling: false, + } executions.set(key, execution) // The leading yield lets `owner` be assigned before the drain can settle, and keeps // failing self-waking executions from growing the stack across successor starts. // Drains start one tick after wake; callers observe progress through events or run. execution.owner = fork( Effect.yieldNow.pipe( + Effect.andThen(Effect.uninterruptible(options.started?.(key) ?? Effect.void)), Effect.andThen(loop(key, execution, force)), - Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void), + Effect.onExit((exit) => + Effect.sync(() => { + execution.settling = true + }).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)), + ), Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.exit, Effect.asVoid, @@ -85,7 +99,7 @@ export const make = (options: { // A doorbell that survives the execution loop (rung after the loop decided to end, or // during failure or interruption cleanup) starts a fresh execution for the remaining work. - const settle = (key: Key, execution: Execution, exit: Exit.Exit) => { + const settle = (key: Key, execution: Execution, exit: Exit.Exit) => { if (execution.pendingWake) start(key, false) else executions.delete(key) Deferred.doneUnsafe(execution.done, exit) @@ -112,12 +126,13 @@ export const make = (options: { start(key, false) }) - const interrupt = (key: Key): Effect.Effect => + const interrupt = (key: Key, reason?: Reason): Effect.Effect => Effect.suspend(() => { const execution = executions.get(key) - if (execution?.owner === undefined) return Effect.void + if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void execution.stopping = true execution.pendingWake = false + execution.interruptionReason = reason return Fiber.interrupt(execution.owner) }) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 3decb42f05..910b05e745 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,13 +3,20 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { MessageDecodeError } from "../error" +import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" import { SessionRunnerModel } from "./model" import type { Instructions } from "../../instructions/index" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = - LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error + | LLMError + | SessionRunnerModel.Error + | MessageDecodeError + | AgentNotFoundError + | StepFailedError + | UserInterruptedError + | Instructions.InitializationBlocked + | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ export interface Interface { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d696dd7ff0..261760568f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -10,12 +10,15 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Money } from "@opencode-ai/schema/money" +import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" +import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" import { Instructions } from "../../instructions/index" import { InstructionBuiltIns } from "../../instructions/builtins" @@ -32,6 +35,7 @@ import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" import { SessionHistory } from "../history" import { SessionInput } from "../input" +import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionTitle } from "../title" @@ -44,6 +48,36 @@ import { SessionRunnerSystemPrompt } from "./system-prompt" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" +import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error" +import { toSessionError } from "../to-session-error" +import { SessionRunnerRetry } from "./retry" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import { PluginHooks } from "../../plugin/hooks" +import { PluginSupervisor } from "../../plugin/supervisor" + +type StepTokens = { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } +} + +// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract. +export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) { + const context = tokens.input + tokens.cache.read + tokens.cache.write + const tier = costs + .filter((cost) => cost.tier?.type === "context" && context > cost.tier.size) + .toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0] + const cost = tier ?? costs.find((cost) => cost.tier === undefined) + if (!cost) return Money.USD.zero + return Money.USD.make( + (tokens.input * cost.input + + (tokens.output + tokens.reasoning) * cost.output + + tokens.cache.read * cost.cache.read + + tokens.cache.write * cost.cache.write) / + 1_000_000, + ) +} /** * Runs one durable coding-agent Session until it settles. @@ -54,10 +88,10 @@ import { llmClient } from "../../effect/app-node-platform" * - Session ownership and controls * - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce. * - [ ] Replace local ownership with durable multi-node ownership when clustered. - * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. + * - [x] Publish durable historical execution lifecycle and bounded retry observations. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. * - [x] Honor optional agent step limits. - * - [ ] Bound provider retries and repeated identical tool calls. + * - [ ] Bound repeated identical tool calls (provider retries are bounded). * * - Runtime context assembly * - Track V1 runtime-context parity canonically in `specs/v2/session.md`. @@ -66,7 +100,7 @@ import { llmClient } from "../../effect/app-node-platform" * - [x] Translate every projected V2 Session message variant into canonical * `@opencode-ai/llm` messages. * - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions. - * - [x] Stream exactly one `llm.stream(request)` physical attempt. + * - [x] Stream exactly one `llm.stream(request)` call per attempt. * - [x] Persist assistant text and usage events incrementally as they arrive. * - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive. * - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive. @@ -87,7 +121,7 @@ import { llmClient } from "../../effect/app-node-platform" * - [ ] Coalesce streamed deltas and add covering projected-history indexes. * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * - * Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here. + * Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here. * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one @@ -102,6 +136,7 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service + const hooks = yield* PluginHooks.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service @@ -115,6 +150,7 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service + const plugins = yield* PluginSupervisor.Service // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. @@ -132,24 +168,18 @@ const layer = Layer.effect( for (const message of yield* store.context(sessionID)) { if (message.type !== "assistant") continue for (const tool of message.content) { - if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue + if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue yield* events.publish(SessionEvent.Tool.Failed, { sessionID, assistantMessageID: message.id, callID: tool.id, - error: { type: "unknown", message: "Tool execution interrupted" }, - provider: { - executed: tool.provider?.executed === true, - ...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }), - }, + error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` }, + executed: tool.executed === true, }) } } }) - const awaitToolFibers = (fibers: FiberSet.FiberSet) => - Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) - // Declining an interactive prompt halts the drain instead of becoming model-facing tool output. const isUserDeclined = (cause: Cause.Cause) => cause.reasons.some( @@ -176,11 +206,15 @@ const layer = Layer.effect( promotion: SessionInput.Delivery | undefined, step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, + assistantMessageID?: SessionMessage.ID, ) { const session = yield* getSession(sessionID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt + yield* plugins.flush const agent = yield* agents.select(session.agent) + const agentInfo = agent.info + if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }) // Establish what the model knows before admitting what the user said, so // a blocked first step leaves pending inputs untouched. const checkpoint = yield* InstructionCheckpoint.prepare( @@ -189,8 +223,6 @@ const layer = Layer.effect( loadInstructions(agent, session.id), session.id, ) - const toolFibers = yield* FiberSet.make() - let needsContinuation = false let currentStep = step if (promotion) { let promoted = 0 @@ -203,31 +235,59 @@ const layer = Layer.effect( } const resolved = yield* models.resolve(session) const model = resolved.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps + const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps const toolMaterialization = isLastStep ? undefined - : yield* tools.materialize({ permissions: agent.info?.permissions, model }) + : yield* tools.materialize({ permissions: agentInfo.permissions, model }) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, providerOptions: { openai: { promptCacheKey } }, - system: [ - agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), - checkpoint.baseline, - ] + system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ - ...toLLMMessages(context, resolved.ref), + ...toLLMMessages(context, resolved.ref, providerMetadataKey), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), ], tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + const toolFibers = yield* FiberSet.make() + const ownedToolFibers: Array> = [] + let needsContinuation = false + const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) + const requestEvent: SessionHooks["request"] = { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + system: [...request.system], + messages: [...request.messages], + tools: Object.fromEntries( + request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + ), + } + // Plugins may reshape the draft, but cannot advertise tools excluded earlier + // by permissions or registration state. + yield* hooks.trigger("session", "request", requestEvent) + const hookedRequest = LLM.updateRequest(request, { + system: requestEvent.system, + messages: requestEvent.messages, + tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { + const registered = availableTools.get(name) + if (!registered) return [] + return [{ ...registered, description: tool.description, inputSchema: tool.input }] + }), + }) + const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) // Automatic compaction completed; rebuild the request from compacted history. - if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) + if ( + !(yield* SessionInput.pendingCompaction(db, session.id)) && + (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest })) + ) return { _tag: "RestartAfterCompaction", step: currentStep } as const const startSnapshot = yield* snapshots.capture() const publisher = createLLMEventPublisher(events, { @@ -236,21 +296,22 @@ const layer = Layer.effect( // The selected catalog identity, not model.id: route-level ids are provider API // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). model: resolved.ref, + providerMetadataKey, snapshot: startSnapshot, + assistantMessageID, }) const publication = Semaphore.makeUnsafe(1) // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) - const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => - serialized(publisher.publish(event, outputPaths)) + const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(request).pipe( + const providerStream = llm.stream(hookedRequest).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return if (LLMEvent.is.providerError(event)) { - if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) { + if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) { overflowFailure = event return } @@ -258,38 +319,73 @@ const layer = Layer.effect( yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return if (!toolMaterialization) { - yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + yield* serialized( + publisher.failUnsettledTools({ + type: "tool.execution", + message: "Tools are disabled after the maximum agent steps", + }), + ) + return + } + // A request hook hid this registered tool from the current request. Fail only + // this call durably and continue so the model can react, instead of executing + // a tool that was not advertised. Unregistered tools flow through settle, which + // durably fails them as unknown. + if (!advertisedTools.has(event.name) && availableTools.has(event.name)) { + needsContinuation = true + yield* publish( + LLMEvent.toolError({ + id: event.id, + name: event.name, + message: `Tool is not available for this request: ${event.name}`, + }), + ) return } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) - yield* Effect.uninterruptibleMask((restore) => - restore( - toolMaterialization.settle({ - sessionID: session.id, - agent: agent.id, - assistantMessageID, - call: event, - }), - ).pipe( - Effect.flatMap((settlement) => - publish( - LLMEvent.toolResult({ - id: event.id, - name: event.name, - result: settlement.result, - output: settlement.output, - }), - settlement.outputPaths ?? [], + ownedToolFibers.push( + yield* Effect.uninterruptibleMask((restore) => + restore( + toolMaterialization.settle({ + sessionID: session.id, + agent: agent.id, + assistantMessageID, + call: event, + }), + ).pipe( + Effect.flatMap((settlement) => + publish( + LLMEvent.toolResult({ + id: event.id, + name: event.name, + result: settlement.result, + output: settlement.output, + }), + settlement.error, + ).pipe( + Effect.andThen( + settlement.error?.type === "permission.rejected" + ? serialized(publisher.failAssistant(settlement.error)).pipe( + Effect.andThen(Effect.fail(new UserInterruptedError())), + ) + : Effect.void, + ), + ), ), ), - ), - ).pipe(FiberSet.run(toolFibers)) + ).pipe(FiberSet.run(toolFibers)), + ) }), ), Effect.ensuring(serialized(publisher.flush())), ) + const stepUsage = (settlement: NonNullable>) => ({ + cost: calculateCost(resolved.cost, settlement.tokens), + tokens: settlement.tokens, + }) + // Captures the end snapshot, diffs it against the step's start, and durably ends the // assistant step. const publishStepEnd = (settlement: NonNullable>) => @@ -306,8 +402,7 @@ const layer = Layer.effect( sessionID: session.id, assistantMessageID: yield* publisher.startAssistant(), finish: settlement.finish, - cost: 0, - tokens: settlement.tokens, + ...stepUsage(settlement), snapshot: endSnapshot, files, }), @@ -327,64 +422,119 @@ const layer = Layer.effect( // restart the step instead of surfacing the provider error. if ( recoverOverflow && - !publisher.hasAssistantStarted() && + !publisher.hasRetryEvidence() && isContextOverflowFailure(overflowFailure ?? streamFailure) && (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request }))) ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const // An unrecovered held-back overflow becomes the step's durable provider error. A - // thrown LLM failure fails hosted tool calls and the assistant unless a provider - // error was already recorded from the stream. + // thrown LLM failure records the assistant failure unless a provider error was + // already recorded from the stream. Terminal publication waits for owned tools. if (overflowFailure) yield* publish(overflowFailure) const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined if (llmFailure && !publisher.hasProviderError()) { - yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* serialized(publisher.failAssistant(llmFailure.reason.message)) + const error = toSessionError(llmFailure) + if ( + SessionRunnerRetry.isRetryable(llmFailure) && + !publisher.hasRetryEvidence() && + (agentInfo.steps === undefined || currentStep < agentInfo.steps) + ) { + return yield* new SessionRunnerRetry.RetryableFailure({ + cause: llmFailure, + assistantMessageID: yield* publisher.startAssistant(), + error, + step: currentStep, + }) + } + yield* serialized(publisher.failAssistant(error)) } // Provider error events only arrive from the stream, so the flag is final here. const providerFailed = publisher.hasProviderError() - // Settle tool fibers: an interrupted stream abandons unstarted tool work first. + // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain + // the individual fibers and await all exits before publishing the terminal step event. if (streamInterrupted) yield* FiberSet.clear(toolFibers) - const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) - const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause) - const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause) + const settled = yield* restore( + Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }), + ).pipe(Effect.exit) + const settledCauses = + settled._tag === "Failure" + ? [settled.cause] + : settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : [])) + const toolsInterrupted = settledCauses.some(Cause.hasInterrupts) + const userDeclined = settledCauses.some(isUserDeclined) + const permissionRejected = settledCauses.some( + (cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError, + ) - if (userDeclined || streamInterrupted || toolsInterrupted) { + if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) - yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) - yield* serialized(publisher.failAssistant("Step interrupted")) - if (userDeclined) return yield* Effect.interrupt + yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" })) + yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" })) } // A settled tool fiber failure is one of two things. A defect from a tool // implementation becomes a failed tool call the model can read, and the step still // settles so the model may recover. A typed infrastructure failure (tool output // could not be persisted) also fails the assistant and then fails the drain. - const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined + const settledFailure = settledCauses.find( + (cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected, + ) const infraError = settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure)) if (settledFailure !== undefined) { const failure = infraError ?? Cause.squash(settledFailure) - const message = failure instanceof Error ? failure.message : String(failure) - yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) - if (infraError !== undefined) - yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`)) + const error = toSessionError(failure) + yield* serialized(publisher.failUnsettledTools(error)) + if (infraError !== undefined) yield* serialized(publisher.failAssistant(error)) } + // Fail unresolved calls before the terminal step event. Local calls have joined, so + // these sweeps only close calls that could not produce a truthful settlement. + if (providerFailed) + yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" })) + if (llmFailure && !providerFailed) + yield* serialized( + publisher.failUnsettledTools( + { + type: "tool.result-missing", + message: "Provider did not return a tool result", + }, + true, + ), + ) + const hostedResultMissing = + stream._tag === "Success" && !providerFailed + ? yield* serialized( + publisher.failUnsettledTools( + { type: "tool.result-missing", message: "Provider did not return a tool result" }, + true, + ), + ) + : false + if (hostedResultMissing && !publisher.stepSettlement()) + yield* serialized( + publisher.failAssistant({ + type: "tool.result-missing", + message: "Provider did not return a tool result", + }), + ) + + const stepFailure = publisher.stepFailure() const stepSettlement = publisher.stepSettlement() const stepEndedCleanly = - !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed + !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement) - // A provider error orphans recorded local calls; a clean stream can still leave - // hosted calls without results. - if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) - if (stream._tag === "Success" && !providerFailed) - yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true)) + if (stepFailure) + yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) - if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined)) - return yield* Effect.failCause(settled.cause) + if (userDeclined) return yield* Effect.interrupt + if (permissionRejected) return yield* new UserInterruptedError() + if ((toolsInterrupted || infraError !== undefined) && settledFailure) + return yield* Effect.failCause(settledFailure) + if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause) + if (stepFailure) return yield* new StepFailedError({ error: stepFailure }) return { _tag: "Completed", needsContinuation: !providerFailed && needsContinuation, @@ -405,8 +555,31 @@ const layer = Layer.effect( let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow let currentPromotion = promotion let currentStep = step + let assistantMessageID: SessionMessage.ID | undefined while (true) { - const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow) + const attempt = yield* Effect.suspend(() => + attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID), + ).pipe( + Effect.tapError((error) => + error instanceof SessionRunnerRetry.RetryableFailure + ? Effect.sync(() => { + currentStep = error.step + 1 + assistantMessageID = error.assistantMessageID + currentPromotion = undefined + }) + : Effect.void, + ), + Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => { + if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error) + return events + .publish(SessionEvent.Step.Failed, { + sessionID, + assistantMessageID: error.assistantMessageID, + error: error.error, + }) + .pipe(Effect.andThen(Effect.fail(error.cause))) + }), + ) if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step } if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined yield* Effect.yieldNow @@ -415,12 +588,54 @@ const layer = Layer.effect( } }) - // ExecutionSettled is published per execution (busy period) by SessionExecution, not per - // drain here. + const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* ( + sessionID: SessionSchema.ID, + ) { + const pending = yield* SessionInput.pendingCompaction(db, sessionID) + if (!pending) return false + const session = yield* getSession(sessionID) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const compacted = yield* restore( + Effect.gen(function* () { + return yield* compaction.compactManual({ + session, + messages: yield* store.context(sessionID), + inputID: pending.id, + }) + }), + ).pipe(Effect.exit) + if (Exit.isSuccess(compacted) && compacted.value) return true + if (Exit.isFailure(compacted)) { + const unsettled = yield* SessionInput.pendingCompaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, + inputID: unsettled.id, + }) + return yield* Effect.failCause(compacted.cause) + } + const unsettled = yield* SessionInput.pendingCompaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: { type: "compaction.failed", message: "Compaction could not start" }, + inputID: unsettled.id, + }) + return true + }), + ) + }) + + // Execution lifecycle is published per busy period by SessionExecution, not per drain here. const drain = Effect.fn("SessionRunner.drain")(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) { + yield* runPendingCompaction(input.sessionID) const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (!input.force && !hasSteer && !hasQueue) return @@ -444,11 +659,19 @@ const layer = Layer.effect( } needsContinuation = result.needsContinuation step = result.step + 1 + if (needsContinuation) { + promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer" + continue + } + yield* runPendingCompaction(input.sessionID) promotion = "steer" - if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") + needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") } - shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = shouldRun ? "queue" : undefined + yield* runPendingCompaction(input.sessionID) + const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") + const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") + shouldRun = hasSteer || hasQueue + promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined } }) @@ -464,6 +687,7 @@ export const node = makeLocationNode({ llmClient, AgentV2.node, ToolRegistry.node, + PluginHooks.node, SessionRunnerModel.node, SessionStore.node, Location.node, @@ -478,5 +702,6 @@ export const node = makeLocationNode({ Config.node, Snapshot.node, Database.node, + PluginSupervisor.node, ], }) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6cc0d0845d..defd95f9c1 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -82,6 +82,8 @@ export interface Resolved { readonly model: Model /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ readonly ref: ModelV2.Ref + /** Catalog pricing in dollars per million tokens. */ + readonly cost: ModelV2.Info["cost"] } export interface Interface { @@ -94,13 +96,14 @@ export class Service extends Context.Service()("@opencode/v2 export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ -export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ +export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({ model, ref: ModelV2.Ref.make({ id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), ...(variant === undefined ? {} : { variant }), }), + cost, }) const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { @@ -341,6 +344,7 @@ const layer = Layer.effect( providerID: selected.providerID, ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }), + cost: selected.cost, } }), }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 9a334a0a6d..dfd0dca499 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,16 +1,22 @@ import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm" -import { DateTime, Effect } from "effect" +import { Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Money } from "@opencode-ai/schema/money" +import { AgentV2 } from "../../agent" +import { Snapshot } from "../../snapshot" type Input = { readonly sessionID: SessionSchema.ID - readonly agent: string + readonly agent: AgentV2.ID readonly model: ModelV2.Ref - readonly snapshot?: string + readonly providerMetadataKey: string + readonly snapshot?: Snapshot.ID + readonly assistantMessageID?: SessionMessage.ID } const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0) @@ -41,10 +47,10 @@ const message = (value: unknown) => { type SettledOutput = | { readonly structured: Record; readonly content: ToolOutput["content"] } - | { readonly error: { readonly type: "unknown"; readonly message: string } } + | { readonly error: SessionError.Error } const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { - if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } } + if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } } const settled = value ?? ToolOutput.fromResultValue(result) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) return { structured: record(settled.structured), content: settled.content } @@ -61,22 +67,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) called: boolean settled: boolean providerExecuted: boolean - providerMetadata?: ProviderMetadata } >() - const timestamp = DateTime.now - let assistantMessageID: SessionMessage.ID | undefined - let assistantActive = false - let assistantFailed = false + let assistantMessageID = input.assistantMessageID + let stepStarted = false + let stepFailed = false let providerFailed = false - let stepSettlement: { readonly finish: string; readonly tokens: ReturnType } | undefined + let retryEvidence = false + let stepFailure: SessionError.Error | undefined + let stepSettlement: + | { + readonly finish: Extract["reason"] + readonly tokens: ReturnType + } + | undefined const startAssistant = Effect.fnUntraced(function* () { - if (assistantMessageID !== undefined) return assistantMessageID - assistantMessageID = SessionMessage.ID.create() - assistantActive = true + if (stepStarted && assistantMessageID !== undefined) return assistantMessageID + assistantMessageID ??= SessionMessage.ID.create() + stepStarted = true yield* events.publish(SessionEvent.Step.Started, { - ...input, + sessionID: input.sessionID, + agent: input.agent, + model: input.model, assistantMessageID, snapshot: input.snapshot, }) @@ -86,29 +99,42 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID === undefined ? Effect.die(new Error("Tool event before assistant step start")) : Effect.succeed(assistantMessageID) - + const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey] const fragments = ( name: string, - ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect, + ended: (id: string, value: string, ordinal: number, state?: Record) => Effect.Effect, + single = false, ) => { - const chunks = new Map() - const start = (id: string) => + const chunks = new Map< + string, + { readonly ordinal: number; readonly values: string[]; state?: Record } + >() + let nextOrdinal = 0 + const start = (id: string, state?: Record) => Effect.suspend(() => { if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) - chunks.set(id, []) - return Effect.void + if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) + const ordinal = nextOrdinal++ + chunks.set(id, { ordinal, values: [], state }) + return Effect.succeed(ordinal) }) - const append = (id: string, value: string) => + const append = (id: string, value: string, state?: Record) => Effect.suspend(() => { const current = chunks.get(id) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) - current.push(value) - return Effect.void + current.values.push(value) + if (state !== undefined) current.state = { ...current.state, ...state } + return Effect.succeed(current.ordinal) }) - const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) { + const end = Effect.fnUntraced(function* (id: string, state?: Record) { const current = chunks.get(id) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) - yield* ended(id, current.join(""), providerMetadata) + yield* ended( + id, + current.values.join(""), + current.ordinal, + state === undefined ? current.state : { ...current.state, ...state }, + ) chunks.delete(id) }) const flush = Effect.fnUntraced(function* () { @@ -117,26 +143,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { start, append, end, flush } } - const text = fragments("text", (textID, value) => - Effect.gen(function* () { - yield* events.publish(SessionEvent.Text.Ended, { - sessionID: input.sessionID, - assistantMessageID: yield* currentAssistantMessageID(), - textID, - text: value, - }) - }), + const text = fragments( + "text", + (_textID, value, ordinal) => + Effect.gen(function* () { + yield* events.publish(SessionEvent.Text.Ended, { + sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), + ordinal, + text: value, + }) + }), + true, ) - const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) => - Effect.gen(function* () { - yield* events.publish(SessionEvent.Reasoning.Ended, { - sessionID: input.sessionID, - assistantMessageID: yield* currentAssistantMessageID(), - reasoningID, - text: value, - providerMetadata, - }) - }), + const reasoning = fragments( + "reasoning", + (_reasoningID, value, ordinal, state) => + Effect.gen(function* () { + yield* events.publish(SessionEvent.Reasoning.Ended, { + sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), + ordinal, + text: value, + state, + }) + }), + true, ) const toolInput = fragments("tool input", (callID, value) => Effect.gen(function* () { @@ -191,37 +223,45 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* flushFragments() }) - const failAssistant = Effect.fnUntraced(function* (message: string) { - if (assistantFailed) return + const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) { yield* flush() + yield* startAssistant() + if (replace || stepFailure === undefined) stepFailure = error + }) + + const publishStepFailure = Effect.fnUntraced(function* (usage?: { + readonly cost: Money.USD + readonly tokens: ReturnType + }) { + if (stepFailed || stepFailure === undefined) return const assistantMessageID = yield* startAssistant() - assistantActive = false - assistantFailed = true + stepFailed = true yield* events.publish(SessionEvent.Step.Failed, { sessionID: input.sessionID, assistantMessageID, - error: { type: "unknown", message }, + error: stepFailure, + ...usage, }) }) const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( - message: string, + error: SessionError.Error, hostedOnly = false, ) { + let failed = false for (const [callID, tool] of tools) { if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue tool.settled = true + failed = true yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID, - error: { type: "unknown", message }, - provider: { - executed: tool.providerExecuted, - ...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }), - }, + error, + executed: tool.providerExecuted, }) } + return failed }) const assistantMessageIDForTool = (callID: string) => { @@ -229,27 +269,26 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`)) } - const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( - event: LLMEvent, - outputPaths: ReadonlyArray = [], - ) { + const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) { switch (event.type) { case "step-start": + yield* startAssistant() return case "text-start": - yield* text.start(event.id) + retryEvidence = true + const startedTextOrdinal = yield* text.start(event.id) yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - textID: event.id, + ordinal: startedTextOrdinal, }) return case "text-delta": - yield* text.append(event.id, event.text) + const deltaTextOrdinal = yield* text.append(event.id, event.text) yield* events.publish(SessionEvent.Text.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - textID: event.id, + ordinal: deltaTextOrdinal, delta: event.text, }) return @@ -257,27 +296,33 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* text.end(event.id) return case "reasoning-start": - yield* reasoning.start(event.id) + retryEvidence = true + const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata)) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - reasoningID: event.id, - providerMetadata: event.providerMetadata, + ordinal: startedReasoningOrdinal, + state: providerState(event.providerMetadata), }) return case "reasoning-delta": - yield* reasoning.append(event.id, event.text) + const deltaReasoningOrdinal = yield* reasoning.append( + event.id, + event.text, + providerState(event.providerMetadata), + ) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - reasoningID: event.id, + ordinal: deltaReasoningOrdinal, delta: event.text, }) return case "reasoning-end": - yield* reasoning.end(event.id, event.providerMetadata) + yield* reasoning.end(event.id, providerState(event.providerMetadata)) return case "tool-input-start": + retryEvidence = true yield* startToolInput(event) return case "tool-input-delta": { @@ -299,6 +344,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* endToolInput(event) return case "tool-call": { + retryEvidence = true if (!tools.has(event.id)) yield* startToolInput(event) const tool = tools.get(event.id)! if (!tool.inputEnded) yield* endToolInput(event) @@ -307,21 +353,18 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) tool.called = true tool.providerExecuted = event.providerExecuted === true - tool.providerMetadata = event.providerMetadata yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, - tool: event.name, input: record(event.input), - provider: { - executed: tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - }, + executed: tool.providerExecuted, + state: providerState(event.providerMetadata), }) return } case "tool-result": { + retryEvidence = true const tool = tools.get(event.id) if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`)) if (tool.name !== event.name) @@ -331,11 +374,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) } tool.settled = true - const result = settledOutput(event.output, event.result) - const provider = { - executed: event.providerExecuted === true || tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - } + const result = error ? { error } : settledOutput(event.output, event.result) + const executed = event.providerExecuted === true || tool.providerExecuted + const resultState = providerState(event.providerMetadata) if ("error" in result) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, @@ -343,7 +384,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) callID: event.id, error: result.error, result: event.result, - provider, + executed, + resultState, }) return } @@ -352,13 +394,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID: tool.assistantMessageID, callID: event.id, ...result, - outputPaths, - ...(provider.executed ? { result: event.result } : {}), - provider, + ...(executed ? { result: event.result } : {}), + executed, + resultState, }) return } case "tool-error": { + retryEvidence = true const tool = tools.get(event.id) if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`)) if (tool.name !== event.name) @@ -369,25 +412,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, - error: { type: "unknown", message: event.message }, - provider: { - executed: tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - }, + error: + event.message === `Unknown tool: ${event.name}` + ? { type: "tool.unknown", message: event.message } + : { type: "tool.execution", message: event.message }, + executed: tool.providerExecuted, + resultState: providerState(event.providerMetadata), }) return } case "step-finish": yield* flush() - assistantActive = false if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } + if (event.reason === "content-filter") { + providerFailed = true + yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) + return + } return case "finish": return case "provider-error": providerFailed = true - yield* failAssistant(event.message) + yield* failAssistant({ type: "provider.unknown", message: event.message }, true) return } }) @@ -396,10 +444,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) publish, flush, failAssistant, + publishStepFailure, failUnsettledTools, - hasActiveAssistant: () => assistantActive, - hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, + hasRetryEvidence: () => retryEvidence, + stepFailure: () => stepFailure, stepSettlement: () => stepSettlement, startAssistant, assistantMessageID: assistantMessageIDForTool, diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts new file mode 100644 index 0000000000..3ada83b4e4 --- /dev/null +++ b/packages/core/src/session/runner/retry.ts @@ -0,0 +1,67 @@ +export * as SessionRunnerRetry from "./retry" + +import { LLMError } from "@opencode-ai/llm" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Data, Duration, Effect, Schedule } from "effect" +import { EventV2 } from "../../event" +import { SessionEvent } from "../event" +import { SessionMessage } from "../message" +import { SessionSchema } from "../schema" +import type { SessionRunner } from "./index" + +export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{ + readonly cause: LLMError + readonly assistantMessageID: SessionMessage.ID + readonly error: SessionError.Error + readonly step: number +}> {} + +export function isRetryable(error: LLMError) { + switch (error.reason._tag) { + case "RateLimit": + case "ProviderInternal": + case "Transport": + return true + case "Authentication": + case "QuotaExceeded": + case "ContentPolicy": + case "InvalidProviderOutput": + case "InvalidRequest": + case "NoRoute": + case "UnknownProvider": + return false + default: { + const exhaustive: never = error.reason + return exhaustive + } + } +} + +const retryAfter = (failure: RetryableFailure) => { + if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal") + return failure.cause.reason.retryAfterMs + return undefined +} + +export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) => + Schedule.exponential("2 seconds").pipe( + Schedule.take(4), + Schedule.setInputType(), + Schedule.passthrough, + Schedule.while(({ input }) => input instanceof RetryableFailure), + Schedule.modifyDelay((failure, delay) => { + const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined + return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))) + }), + Schedule.tap((metadata) => + metadata.input instanceof RetryableFailure + ? events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: metadata.input.assistantMessageID, + attempt: metadata.attempt + 1, + at: metadata.now + Duration.toMillis(metadata.duration), + error: metadata.input.error, + }) + : Effect.void, + ), + ) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 0663ac8e6d..7aa379ab27 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -21,30 +21,60 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) -const textAttachment = (file: FileAttachment) => - Message.make({ - role: "user", - content: [ - `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, - file.description === undefined ? undefined : `Description: ${file.description}`, - "", - Buffer.from(file.data, "base64").toString("utf8"), - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - metadata: { - attachment: { - source: file.source, - name: file.name, - description: file.description, - }, +const textAttachment = (file: FileAttachment): ContentPart => ({ + type: "text", + text: `\n\n${[ + `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + "", + Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n")}`, + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, }, - }) + }, +}) + +const directoryAttachment = (file: FileAttachment): ContentPart => ({ + type: "text", + text: `\n\n${[ + `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + file.data.length === 0 ? undefined : "", + file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n")}`, + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, + }, + }, +}) + +const attachmentContent = (file: FileAttachment): ContentPart[] => { + if (file.mime === "text/plain") return [textAttachment(file)] + if (file.mime === "application/x-directory") return [directoryAttachment(file)] + if (imageMimes.has(file.mime)) return [media(file)] + return [] +} const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const providerMetadata = ( + provider: string, + state: Record | undefined, +): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state }) + const toolInput = (tool: SessionMessage.AssistantTool) => - tool.state.status === "pending" + tool.state.status === "streaming" ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) : tool.state.input @@ -53,7 +83,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider id: tool.id, name: tool.name, input: toolInput(tool), - providerExecuted: tool.provider?.executed, + providerExecuted: tool.executed, providerMetadata, }) @@ -62,14 +92,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid // TODO: Materialize remote and managed URIs before provider-history lowering. // ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes. const result = - tool.provider?.executed === true && tool.state.result !== undefined + tool.executed === true && tool.state.result !== undefined ? tool.state.result : ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content }) return ToolResultPart.make({ id: tool.id, name: tool.name, result, - providerExecuted: tool.provider?.executed, + providerExecuted: tool.executed, providerMetadata, }) } @@ -78,39 +108,44 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid id: tool.id, name: tool.name, result: - tool.provider?.executed === true && tool.state.result !== undefined + tool.executed === true && tool.state.result !== undefined ? tool.state.result : { error: tool.state.error, content: tool.state.content, structured: tool.state.structured }, resultType: "error", - providerExecuted: tool.provider?.executed, + providerExecuted: tool.executed, providerMetadata, }) } } -const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { +const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => { const sameModel = String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id) const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") - return sameModel + return reuseProviderMetadata ? [ { type: "reasoning", text: item.text, - providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + providerMetadata: providerMetadata(providerMetadataKey, item.state), }, ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) - if (item.provider?.executed !== true) return [call] + const call = toolCall( + item, + reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined, + ) + if (item.executed !== true) return [call] const result = toolResult( item, - reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, + reuseProviderMetadata + ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) + : undefined, ) return result ? [call, result] : [call] }) @@ -120,9 +155,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) }) const results = message.content - .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) + .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true) .map((item) => - toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), + toolResult( + item, + reuseProviderMetadata + ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) + : undefined, + ), ) .filter((message) => message !== undefined) .map(Message.tool) @@ -133,24 +173,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { ] } -function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] { +function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] { switch (message.type) { case "agent-switched": case "model-switched": return [] case "user": - const files = message.files ?? [] + const content = [ + ...(message.text === "" ? [] : [Message.text(message.text)]), + ...(message.files ?? []).flatMap(attachmentContent), + ] + if (content.length === 0) return [] return [ - ...files - .filter((file) => file.mime === "text/plain") - .map(textAttachment), Message.make({ id: message.id, role: "user", - content: [ - { type: "text", text: message.text }, - ...files.filter((file) => imageMimes.has(file.mime)).map(media), - ], + content, metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), @@ -168,13 +206,14 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess Message.make({ id: message.id, role: "user", - content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`, + content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`, metadata: message.metadata, }), ] case "assistant": - return assistant(message, model) + return assistant(message, model, providerMetadataKey) case "compaction": + if (message.status !== "completed") return [] return [ Message.make({ id: message.id, @@ -197,5 +236,8 @@ ${message.recent} } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) => - messages.flatMap((message) => toLLMMessage(message, model)) +export const toLLMMessages = ( + messages: readonly SessionMessage.Info[], + model: ModelV2.Ref, + providerMetadataKey: string = model.providerID, +) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 3e6f79863c..6e09505e3c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,10 +1,11 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" +import { sql } from "drizzle-orm" import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" -import type { Snapshot } from "../snapshot" +import type { FileDiff } from "@opencode-ai/schema/file-diff" import { PermissionV1 } from "../v1/permission" import { ProjectV2 } from "../project" import type { SessionSchema } from "./schema" @@ -12,10 +13,11 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { Instructions } from "../instructions/index" -import type { Revert } from "@opencode-ai/schema/revert" +import type { Session } from "@opencode-ai/schema/session" +import type { RevertV1 } from "@opencode-ai/schema/session-revert" import type { Schema } from "effect" -type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> +type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id"> type V1MessageData = Omit type V1PartData = Omit @@ -29,6 +31,8 @@ export const SessionTable = sqliteTable( .references(() => ProjectTable.id, { onDelete: "cascade" }), workspace_id: text().$type(), parent_id: text().$type(), + fork_session_id: text().$type(), + fork_message_id: text().$type(), slug: text().notNull(), directory: directoryColumn().notNull(), path: pathColumn(), @@ -38,7 +42,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), + summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), @@ -46,7 +50,7 @@ export const SessionTable = sqliteTable( tokens_reasoning: integer().notNull().default(0), tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), - revert: text({ mode: "json" }).$type(), + revert: text({ mode: "json" }).$type(), permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ @@ -145,8 +149,9 @@ export const SessionInputTable = sqliteTable( .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - prompt: text({ mode: "json" }).notNull().$type(), - delivery: text().$type().notNull(), + type: text().$type().notNull(), + prompt: text({ mode: "json" }).$type(), + delivery: text().$type(), admitted_seq: integer().notNull(), promoted_seq: integer(), time_created: integer() @@ -154,12 +159,16 @@ export const SessionInputTable = sqliteTable( .$default(() => Date.now()), }, (table) => [ - index("session_input_session_pending_delivery_seq_idx").on( + index("session_input_session_pending_type_delivery_seq_idx").on( table.session_id, table.promoted_seq, + table.type, table.delivery, table.admitted_seq, ), + uniqueIndex("session_input_session_pending_compaction_idx") + .on(table.session_id) + .where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`), uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq), uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), ], diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 6458aa85fb..24c99cf515 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -13,10 +13,10 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: Session.ID) => Effect.Effect - readonly context: (sessionID: Session.ID) => Effect.Effect + readonly context: (sessionID: Session.ID) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, - ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined> + ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined> } export class Service extends Context.Service()("@opencode/v2/SessionStore") {} @@ -25,7 +25,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) return Service.of({ get: Effect.fn("SessionStore.get")(function* (sessionID) { diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts new file mode 100644 index 0000000000..28a015dd7c --- /dev/null +++ b/packages/core/src/session/to-session-error.ts @@ -0,0 +1,56 @@ +import { LLMError, ToolFailure } from "@opencode-ai/llm" +import { SessionError } from "@opencode-ai/schema/session-error" +import { PermissionV2 } from "../permission" +import { QuestionV2 } from "../question" +import { Integration } from "../integration" +import { ToolOutputStore } from "../tool-output-store" +import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error" +import { SessionRunnerModel } from "./runner/model" + +export function toSessionError(cause: unknown): SessionError.Error { + if (cause instanceof LLMError) { + switch (cause.reason._tag) { + case "RateLimit": + return { type: "provider.rate-limit", message: cause.reason.message } + case "Authentication": + return { type: "provider.auth", message: cause.reason.message } + case "QuotaExceeded": + return { type: "provider.quota", message: cause.reason.message } + case "ContentPolicy": + return { type: "provider.content-filter", message: cause.reason.message } + case "Transport": + return { type: "provider.transport", message: cause.reason.message } + case "ProviderInternal": + return { type: "provider.internal", message: cause.reason.message } + case "InvalidProviderOutput": + return { type: "provider.invalid-output", message: cause.reason.message } + case "InvalidRequest": + return { type: "provider.invalid-request", message: cause.reason.message } + case "NoRoute": + return { type: "provider.no-route", message: cause.reason.message } + case "UnknownProvider": + return { type: "provider.unknown", message: cause.reason.message } + default: { + const exhaustive: never = cause.reason + return exhaustive + } + } + } + if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message } + if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message } + if (cause instanceof ToolFailure) + return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error) + if (cause instanceof StepFailedError) return cause.error + if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message } + if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message } + if ( + cause instanceof SessionRunnerModel.ModelNotSelectedError || + cause instanceof SessionRunnerModel.ModelUnavailableError || + cause instanceof SessionRunnerModel.VariantUnavailableError || + cause instanceof SessionRunnerModel.UnsupportedPackageError + ) + return { type: "provider.no-route", message: cause.message } + if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message } + if (cause instanceof ToolOutputStore.StorageError) return { type: "unknown", message: cause.message } + return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) } +} diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 852e19abd7..a62fef8107 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -32,6 +32,7 @@ type Active = { // started after termination resolves immediately from the already-completed deferred. done: Deferred.Deferred timeoutFiber?: Fiber.Fiber + timeout?: (duration: number) => Effect.Effect } /** @@ -50,6 +51,8 @@ export interface Interface { // Resolves once the command reaches a terminal status, returning its final Info. Fails with // NotFoundError if the command is unknown or is removed before it terminates. readonly wait: (id: Shell.ID) => Effect.Effect + // Replaces the running command's timeout from now; zero clears it. + readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect readonly remove: (id: Shell.ID) => Effect.Effect } @@ -124,6 +127,13 @@ export const layer = Layer.effect( return yield* Deferred.await((yield* require(id)).done) }) + const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) { + const session = yield* require(id) + if (session.info.status !== "running" || !session.timeout) return session.info + yield* session.timeout(duration) + return session.info + }) + const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) { const session = yield* require(id) const cursor = input?.cursor ?? 0 @@ -265,16 +275,22 @@ export const layer = Layer.effect( if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) }) - if (input.timeout) { - session.timeoutFiber = runFork( - Effect.sleep(Duration.millis(input.timeout)).pipe( - Effect.flatMap(() => - finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), + session.timeout = (duration) => + Effect.gen(function* () { + if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) + session.timeoutFiber = undefined + if (duration === 0 || session.info.status !== "running") return + session.timeoutFiber = runFork( + Effect.sleep(Duration.millis(duration)).pipe( + Effect.flatMap(() => + finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), + ), + Effect.catch(() => Effect.void), ), - Effect.catch(() => Effect.void), - ), - ) - } + ) + }) + + yield* session.timeout(input.timeout) runFork( handle.exitCode.pipe( @@ -296,7 +312,7 @@ export const layer = Layer.effect( return session.info }) - return Service.of({ create, list, get, wait, output, remove }) + return Service.of({ create, list, get, wait, timeout, output, remove }) }), ) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 511e02af87..13b93004e4 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -28,11 +28,15 @@ export type Source = typeof Source.Type export const Info = Skill.Info export type Info = Skill.Info +export const ID = Skill.ID +export type ID = Skill.ID +export const Name = Skill.Name +export type Name = Skill.Name export const Event = Skill.Event export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => - skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") + skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny") const Frontmatter = Schema.Struct({ name: Schema.String.pipe(Schema.optional), @@ -96,7 +100,7 @@ const layer = Layer.effect( source: Source.key(source), type: source.type, directories: [], - skills: [source.skill.name], + skills: [source.skill.id], }) return { skills: [source.skill], directories: [] } } @@ -112,15 +116,13 @@ const layer = Layer.effect( if (!markdown) continue const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined if (!frontmatter) continue - const name = - frontmatter.name !== undefined - ? frontmatter.name - : path.dirname(filepath) === directory - ? path.basename(filepath, ".md") - : undefined - if (!name) continue + const id = + path.dirname(filepath) === directory + ? path.basename(filepath, ".md") + : path.basename(path.dirname(filepath)) skills.push({ - name, + id: ID.make(id), + name: Name.make(frontmatter.name ?? id), description: frontmatter.description, slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash, autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"), @@ -133,7 +135,7 @@ const layer = Layer.effect( source: Source.key(source), type: source.type, directories, - skills: skills.map((skill) => skill.name), + skills: skills.map((skill) => skill.id), }) return { skills, directories } }) @@ -148,7 +150,7 @@ const layer = Layer.effect( yield* Effect.logInfo("skill cache invalidated", { file, sources: invalidated.map(([key]) => key), - skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)), + skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)), }) yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) }) @@ -159,12 +161,12 @@ const layer = Layer.effect( ) const list = Effect.fn("SkillV2.list")(function* () { - const skills = new Map() + const skills = new Map() for (const source of state.get().sources) { const key = Source.key(source) const loaded = cache.get(key) ?? (yield* load(source)) cache.set(key, loaded) - for (const skill of loaded.skills) skills.set(skill.name, skill) + for (const skill of loaded.skills) skills.set(skill.id, skill) } return Array.from(skills.values()) }) @@ -180,4 +182,8 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [SkillDiscovery.node, FSUtil.node, EventV2.node], +}) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 95ebe87eed..5eae66065b 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -8,7 +8,8 @@ import { SkillV2 } from "../skill" import { Instructions } from "../instructions/index" const Summary = Schema.Struct({ - name: Schema.String, + id: SkillV2.ID, + name: SkillV2.Name, description: Schema.String, }) type Summary = typeof Summary.Type @@ -16,6 +17,7 @@ type Summary = typeof Summary.Type const entries = (skills: ReadonlyArray) => skills.flatMap((skill) => [ " ", + ` ${skill.id}`, ` ${skill.name}`, ` ${skill.description}`, " ", @@ -34,8 +36,8 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name, - (before, after) => before.description !== after.description, + (skill) => skill.id, + (before, after) => before.name !== after.name || before.description !== after.description, ) // Additions and removals render as small deltas; anything else restates the full list. if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) @@ -50,7 +52,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name).join(", ")}.`, + `The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`, ]), ].join("\n") } @@ -77,9 +79,9 @@ const layer = Layer.effect( .flatMap((skill) => skill.description === undefined || skill.autoinvoke === false ? [] - : [{ name: skill.name, description: skill.description }], + : [{ id: skill.id, name: skill.name, description: skill.description }], ) - .toSorted((a, b) => a.name.localeCompare(b.name)) + .toSorted((a, b) => a.id.localeCompare(b.id)) return Instructions.make({ key: Instructions.Key.make("core/skill-guidance"), codec: Schema.toCodecJson(Schema.Array(Summary)), diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 9843e69226..8ef532f488 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -10,10 +10,10 @@ import { Git } from "./git" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath, RelativePath } from "./schema" +import { ID } from "@opencode-ai/schema/snapshot" import { Hash } from "./util/hash" -export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) -export type ID = typeof ID.Type +export { ID } export class Error extends Schema.TaggedErrorClass()("Snapshot.Error", { operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]), @@ -253,12 +253,3 @@ function failure(operation: Error["operation"], cause: unknown) { cause, }) } - -/** Legacy persisted session diff shape. */ -export type LegacyFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 4b427f9835..0b3f9c3628 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -41,7 +41,7 @@ Registrations are scoped: ## Permissions -The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action. +The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action. Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 88feac6c34..927cd3098d 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -6,7 +6,7 @@ */ export * as EditTool from "./edit" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" @@ -113,8 +113,9 @@ export const Plugin = { error instanceof FileMutation.StaleContentError ? new ToolFailure({ message: "File changed after permission approval. Read it again before editing.", + error, }) - : new ToolFailure({ message: `Unable to edit ${input.path}` }), + : new ToolFailure({ message: `Unable to edit ${input.path}`, error }), ), ) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 80f0ca2da4..b047101c39 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,7 +1,7 @@ export * as GlobTool from "./glob" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema } from "effect" import path from "path" import { FileSystem } from "../filesystem" @@ -102,7 +102,7 @@ export const Plugin = { Effect.mapError((error) => error instanceof ToolFailure ? error - : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), + : new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }), ), ), }), diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 9efce1712a..937963f4ca 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,6 +1,6 @@ export * as GrepTool from "./grep" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import path from "path" @@ -133,7 +133,7 @@ export const Plugin = { Effect.mapError((error) => error instanceof ToolFailure ? error - : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), + : new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }), ), ), }), diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/patch.ts similarity index 89% rename from packages/core/src/tool/apply-patch.ts rename to packages/core/src/tool/patch.ts index 2853a9130b..e6a043a0dd 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/patch.ts @@ -1,6 +1,6 @@ -export * as ApplyPatchTool from "./apply-patch" +export * as PatchTool from "./patch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" @@ -12,7 +12,7 @@ import { Patch } from "../patch" import { PermissionV2 } from "../permission" import { Tool } from "./tool" -export const name = "apply_patch" +export const name = "patch" export const Input = Schema.Struct({ patchText: Schema.String.annotate({ @@ -55,8 +55,8 @@ type Prepared = }) export const Plugin = { - id: "opencode.tool.apply-patch", - effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) { + id: "opencode.tool.patch", + effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service @@ -75,12 +75,12 @@ export const Plugin = { toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] - const fail = (path: string) => { + const fail = (path: string, error?: unknown) => { const prefix = applied.length === 0 ? `Unable to apply patch at ${path}` : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` - return new ToolFailure({ message: prefix }) + return new ToolFailure({ message: prefix, error }) } return Effect.gen(function* () { const source = { @@ -91,11 +91,11 @@ export const Plugin = { if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) const hunks = yield* Effect.try({ try: () => Patch.parse(input.patchText), - catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), + catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }), }) if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) - if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) + if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" }) const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] for (const hunk of hunks) @@ -152,7 +152,7 @@ export const Plugin = { before, after: update.content, }) - }).pipe(Effect.mapError(() => fail(hunk.path))) + }).pipe(Effect.mapError((error) => fail(hunk.path, error))) } const patchFiles = prepared.map(patchFile) @@ -182,11 +182,11 @@ export const Plugin = { content: change.content, }) applied.push({ type: change.type, resource: result.resource, target: result.target }) - }).pipe(Effect.mapError(() => fail(change.path))), + }).pipe(Effect.mapError((error) => fail(change.path, error))), { discard: true }, ) return { applied, files: patchFiles } - }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) + }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error)))) }, }), "edit", @@ -194,6 +194,19 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("request", (event) => + Effect.sync(() => { + const usePatch = + event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") + if (usePatch) { + delete event.tools.edit + delete event.tools.write + return + } + delete event.tools.patch + }), + ) }), } diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index e19706f4ae..d5217f8505 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,6 +1,6 @@ export * as QuestionTool from "./question" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { Form } from "../form" @@ -76,7 +76,7 @@ export const Plugin = { source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) .pipe( - Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), + Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })), Effect.andThen( forms .ask({ diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 9ee5b4c42f..001a29ba55 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,6 +1,6 @@ export * as ReadTool from "./read" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { dirname } from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" @@ -134,7 +134,7 @@ export const Plugin = { error instanceof Image.SizeError ? error.message : `Unable to read ${input.path}` - return new ToolFailure({ message }) + return new ToolFailure({ message, error }) }), ) }, diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index de11c10e6f..cef6f3c071 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -14,6 +14,8 @@ import { definition, permission, registrationEntries, RegistrationError, settle, import { Tools } from "./tools" import { ToolHooks } from "./hooks" import { makeLocationNode } from "../effect/app-node" +import { SessionError } from "@opencode-ai/schema/session-error" +import { toSessionError } from "../session/to-session-error" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -45,6 +47,7 @@ export interface Settlement { readonly result: ToolResultValue readonly output?: ToolOutput readonly outputPaths?: ReadonlyArray + readonly error?: SessionError.Error } export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} @@ -86,7 +89,10 @@ const registryLayer = Layer.effect( ).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => - Effect.succeed({ result: { type: "error" as const, value: failure.message } }), + Effect.succeed({ + result: { type: "error" as const, value: failure.message }, + error: toSessionError(failure), + }), ), ) let settlement: Settlement @@ -124,20 +130,19 @@ const registryLayer = Layer.effect( result: afterEvent.result, ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), + ...(settlement.error !== undefined ? { error: settlement.error } : {}), } }) const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) { const registration = local.get(input.call.name)?.at(-1)?.registration - if (!registration) + if (!registration || registration.identity !== advertised) { + const message = `Stale tool call: ${input.call.name}` return { - result: { - type: "error" as const, - value: `Stale tool call: ${input.call.name}`, - }, + result: { type: "error" as const, value: message }, + error: { type: "tool.stale" as const, message }, } - if (registration.identity !== advertised) - return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } + } return yield* settleTool(input, registration.tool) }) @@ -186,12 +191,8 @@ const registryLayer = Layer.effect( const registration = entries.at(-1)?.registration if (registration) registrations.set(name, registration) } - // OpenAI/GPT models use apply_patch; every other model uses edit and write. - const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") for (const [name, registration] of registrations) { - const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch if ( - wrongEditTool || (registration.deferred && !Flag.CODEMODE_ENABLED) || whollyDisabled(permission(registration.tool, name), input.permissions ?? []) ) @@ -215,7 +216,10 @@ const registryLayer = Layer.effect( if (input.call.name === "execute" && execute) return settleTool(input, execute) const registration = direct.get(input.call.name) if (registration) return settleWith(input, registration.identity) - return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) + return Effect.succeed({ + result: { type: "error", value: `Unknown tool: ${input.call.name}` }, + error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` }, + }) }, } }), diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index c4a2820c16..d2b8bb4744 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -2,13 +2,13 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" import { PluginRuntime } from "../plugin/runtime" -import { PositiveInt } from "../schema" +import { NonNegativeInt } from "../schema" import { SessionSchema } from "../session/schema" import { Shell } from "../shell" import { Tool, type Content } from "./tool" @@ -27,10 +27,10 @@ export const Input = Schema.Struct({ workdir: Schema.String.pipe(Schema.optional).annotate({ description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", }), - timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) + timeout: NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) .pipe(Schema.optional) .annotate({ - description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, + description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`, }), background: Schema.Boolean.pipe(Schema.optional).annotate({ description: @@ -143,7 +143,7 @@ export const Plugin = { draft.add( name, Tool.make({ - description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, + description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, input: Input, output: Output, structured: StructuredOutput, @@ -191,7 +191,7 @@ export const Plugin = { if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS + const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS) const info = yield* shell.create({ command: input.command, cwd: target.canonical, @@ -252,6 +252,7 @@ export const Plugin = { .block({ id: job.id, sessionID: context.sessionID }) .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) if (result?.type === "backgrounded") { + yield* shell.timeout(info.id, 0) yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) return { output: BACKGROUND_STARTED, @@ -270,7 +271,9 @@ export const Plugin = { ...(warnings.length ? { warnings } : {}), } }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })), + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }), + ), ), }), ), diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 95c68cb257..1f80bd9684 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -1,6 +1,6 @@ export * as SkillTool from "./skill" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" @@ -13,11 +13,11 @@ export const name = "skill" const FILE_LIMIT = 10 export const Input = Schema.Struct({ - name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), + id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }), }) export const Output = Schema.Struct({ - name: Schema.String, + name: SkillV2.Name, directory: Schema.String, output: Schema.String, }) @@ -27,7 +27,7 @@ export const description = [ "", "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", "", - "The skill name must match one of the available skills in the instructions.", + "The skill ID must match one of the available skills in the instructions.", ].join("\n") export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) => { @@ -70,13 +70,13 @@ export const Plugin = { execute: (input, context) => Effect.gen(function* () { const current = yield* skills.list() - const skill = current.find((skill) => skill.name === input.name) - if (!skill) return yield* unableToLoad(input.name) + const skill = current.find((skill) => skill.id === input.id) + if (!skill) return yield* unableToLoad(input.id) return yield* Effect.gen(function* () { yield* permission.assert({ action: name, - resources: [skill.name], - save: [skill.name], + resources: [skill.id], + save: [skill.id], sessionID: context.sessionID, agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, @@ -94,7 +94,7 @@ export const Plugin = { directory, output: toModelOutput(skill, files), } - }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) }), }), ), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index c5932fce8b..e6c994c216 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,10 +1,11 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" import { PluginRuntime } from "../plugin/runtime" +import { PermissionV2 } from "../permission" import { SessionSchema } from "../session/schema" import { Tool } from "./tool" @@ -42,6 +43,7 @@ export const Plugin = { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service + const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope // Concatenate the child's final completed assistant text. Distinguishes "completed with no @@ -107,13 +109,27 @@ export const Plugin = { .get(context.sessionID) .pipe( Effect.mapError( - () => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), + (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) const agent = yield* agents.resolve(input.agent) if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) + yield* permission + .assert({ + action: name, + resources: [agent.id], + save: [agent.id], + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.assistantMessageID, + callID: context.toolCallID, + }, + }) + .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) // Model selection is policy/config/session state, not an LLM-facing tool argument. const model = agent.model ?? parent.model @@ -128,7 +144,7 @@ export const Plugin = { }) .pipe( Effect.mapError( - () => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), + (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) @@ -176,5 +192,32 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("request", (event) => + Effect.gen(function* () { + const tool = event.tools[name] + if (!tool) return + const selected = yield* agents.resolve(event.agent) + if (!selected) return + const available = (yield* agents.list()) + .filter( + (agent) => + agent.mode !== "primary" && + !agent.hidden && + PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny", + ) + .toSorted((a, b) => a.id.localeCompare(b.id)) + if (available.length === 0) return + tool.description = [ + tool.description, + "", + "Available subagents:", + ...available.map( + (agent) => + `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`, + ), + ].join("\n") + }), + ) }), } diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index f763be0429..96c0f10f1a 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -1,6 +1,6 @@ export * as TodoWriteTool from "./todowrite" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { PermissionV2 } from "../permission" @@ -48,7 +48,7 @@ export const Plugin = { }) yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) return { todos: input.todos } - }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), + }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))), }), ), ) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 0e046daff9..d761645939 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,6 +1,6 @@ export * as WebFetchTool from "./webfetch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -172,7 +172,7 @@ export const Plugin = { format: input.format, output, } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), + }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))), }), ), ) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index bca404e185..9c91d84f92 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,6 +1,6 @@ export * as WebSearchTool from "./websearch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" @@ -246,7 +246,9 @@ export const Plugin = { text: text ?? NO_RESULTS, } }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })), + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), + ), ) }, }), diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 8e8f1b563b..a88063f4e2 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -6,7 +6,7 @@ */ export * as WriteTool from "./write" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" @@ -85,7 +85,9 @@ export const Plugin = { source, }) return yield* files.writeTextPreservingBom({ target, content: input.content }) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), + }).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), + ), }), "edit", ), diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index b85900118a..4b528d2602 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -36,20 +36,24 @@ export namespace EffectFlock { export type LockError = LockTimeoutError | LockCompromisedError + export interface Options { + readonly staleMs?: number + readonly timeoutMs?: number + } + // --------------------------------------------------------------------------- - // Timing (baked in — no caller ever overrides these) + // Timing defaults // --------------------------------------------------------------------------- - const STALE_MS = 60_000 - const TIMEOUT_MS = 5 * 60_000 + const DEFAULT_STALE_MS = 60_000 + const DEFAULT_TIMEOUT_MS = 5 * 60_000 const BASE_DELAY_MS = 100 const MAX_DELAY_MS = 2_000 - const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) - const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( - Schedule.either(Schedule.spaced(MAX_DELAY_MS)), + const retrySchedule = (timeoutMs: number) => Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( + Schedule.either(Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10))))), Schedule.jittered, - Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), + Schedule.while((meta) => meta.elapsed < timeoutMs), ) // --------------------------------------------------------------------------- @@ -73,7 +77,7 @@ export namespace EffectFlock { // --------------------------------------------------------------------------- export interface Interface { - readonly acquire: (key: string, dir?: string) => Effect.Effect + readonly acquire: (key: string, dir?: string, options?: Options) => Effect.Effect readonly withLock: { (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect (body: Effect.Effect, key: string, dir?: string): Effect.Effect @@ -135,9 +139,9 @@ export namespace EffectFlock { ), ) - const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { + const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string, staleMs: number) { const bs = yield* safeStat(breakerPath) - if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) + if (bs && wall() - mtimeMs(bs) > staleMs) yield* forceRemove(breakerPath) return false }) @@ -147,26 +151,31 @@ export namespace EffectFlock { ensuredDirs.add(dir) }) - const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { + const isStale = Effect.fnUntraced(function* ( + lockDir: string, + heartbeatPath: string, + metaPath: string, + staleMs: number, + ) { const now = wall() const hb = yield* safeStat(heartbeatPath) - if (hb) return now - mtimeMs(hb) > STALE_MS + if (hb) return now - mtimeMs(hb) > staleMs const meta = yield* safeStat(metaPath) - if (meta) return now - mtimeMs(meta) > STALE_MS + if (meta) return now - mtimeMs(meta) > staleMs const dir = yield* safeStat(lockDir) if (!dir) return false - return now - mtimeMs(dir) > STALE_MS + return now - mtimeMs(dir) > staleMs }) // -- single lock attempt -- type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } - const tryAcquireLockDir = (lockDir: string, key: string) => + const tryAcquireLockDir = (lockDir: string, key: string, staleMs: number) => Effect.gen(function* () { const token = randomUUID() const metaPath = path.join(lockDir, "meta.json") @@ -176,7 +185,7 @@ export namespace EffectFlock { const created = yield* atomicMkdir(lockDir) if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return yield* new NotAcquired() // Stale — race for breaker ownership const breakerPath = lockDir + ".breaker" @@ -185,7 +194,7 @@ export namespace EffectFlock { Effect.as(true), Effect.catchIf( (e) => e.reason._tag === "AlreadyExists", - () => cleanStaleBreaker(breakerPath), + () => cleanStaleBreaker(breakerPath, staleMs), ), Effect.catchIf(isPathGone, () => Effect.succeed(false)), Effect.orDie, @@ -195,7 +204,7 @@ export namespace EffectFlock { // We own the breaker — double-check staleness, nuke, recreate const recreated = yield* Effect.gen(function* () { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return false yield* forceRemove(lockDir) return yield* atomicMkdir(lockDir) }).pipe(Effect.ensuring(forceRemove(breakerPath))) @@ -218,13 +227,21 @@ export namespace EffectFlock { // -- retry wrapper (preserves Handle type) -- - const acquireHandle = (lockfile: string, key: string): Effect.Effect => - tryAcquireLockDir(lockfile, key).pipe( + const acquireHandle = ( + lockfile: string, + key: string, + options: { staleMs: number; timeoutMs: number }, + ): Effect.Effect => + tryAcquireLockDir(lockfile, key, options.staleMs).pipe( Effect.retry({ while: (err) => err._tag === "NotAcquired", - schedule: retrySchedule, + schedule: retrySchedule(options.timeoutMs), }), Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), + Effect.timeoutOrElse({ + duration: options.timeoutMs, + orElse: () => Effect.fail(new LockTimeoutError({ key })), + }), ) // -- release -- @@ -250,19 +267,27 @@ export namespace EffectFlock { // -- build service -- - const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { + const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string, options: Options = {}) { const lockDir = dir ?? lockRoot + const staleMs = options.staleMs ?? DEFAULT_STALE_MS + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS yield* ensureDir(lockDir) const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") // acquireRelease: acquire is uninterruptible, release is guaranteed - const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) + const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key, { staleMs, timeoutMs }), (handle) => + release(handle), + ) // Heartbeat fiber — scoped, so it's interrupted before release runs yield* fs .utimes(handle.heartbeatPath, new Date(), new Date()) - .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) + .pipe( + Effect.ignore, + Effect.repeat(Schedule.spaced(Math.max(100, Math.floor(staleMs / 3)))), + Effect.forkScoped, + ) }) const withLock: Interface["withLock"] = Function.dual( diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 1e7e3464dc..15b464f776 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -175,7 +175,7 @@ function mcp(info: typeof ConfigV1.Info.Type) { ) const timeout = info.experimental?.mcp_timeout if (!timeout && !Object.keys(servers).length) return undefined - return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } + return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers } } function migrateMcp(info: ConfigMCPV1.Info) { @@ -187,7 +187,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { cwd: info.cwd, environment: info.environment, disabled, - timeout: info.timeout === undefined ? undefined : { request: info.timeout }, + timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout }, } return { type: info.type, @@ -201,7 +201,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { redirect_uri: info.oauth.redirectUri, }, disabled, - timeout: info.timeout === undefined ? undefined : { request: info.timeout }, + timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout }, } } diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 495c125a1e..0a4b7dadce 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" import { AISDK } from "@opencode-ai/core/aisdk" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { LLM } from "@opencode-ai/llm" +import { LLM, Message } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { expect } from "bun:test" import { Effect } from "effect" @@ -51,13 +51,11 @@ it.effect("projects request settings, headers, and body overlays", () => apiKey: "secret", thinkingConfig: { thinkingBudget: 1024 }, }) - const resolved = yield* aisdk.model( - { - ...input, - headers: { "x-test": "header" }, - body: { safety_setting: "strict" }, - }, - ) + const resolved = yield* aisdk.model({ + ...input, + headers: { "x-test": "header" }, + body: { safety_setting: "strict" }, + }) const prepared = yield* LLMClient.prepare( LLM.request({ model: resolved, prompt: "Hello" }), ) @@ -69,3 +67,54 @@ it.effect("projects request settings, headers, and body overlays", () => expect(body).toEqual({ safety_setting: "strict" }) }), ) + +it.effect("projects replay metadata onto AI SDK prompt parts", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const resolved = yield* aisdk.model(model("@ai-sdk/anthropic")) + expect(resolved.route.providerMetadataKey).toBe("anthropic") + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: resolved, + messages: [ + Message.assistant([ + { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } }, + { + type: "tool-call", + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "server_tool_use" } }, + }, + ]), + ], + }), + ) + + expect(prepared.body.prompt).toEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Think", + providerOptions: { anthropic: { signature: "signed" } }, + }, + { + type: "tool-call", + toolCallId: "hosted", + toolName: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerOptions: { anthropic: { blockType: "server_tool_use" } }, + }, + ], + }, + ]) + }), +) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 83f71c3398..ad667f288c 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" @@ -298,13 +299,31 @@ describe("CatalogV2", () => { catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(1), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(10), + output: Money.USDPerMillionTokens.make(10), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] model.time.released = Date.now() }) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f371e19543..f3d1defc68 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, PubSub, Schema, Stream } from "effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { CommandV2 } from "@opencode-ai/core/command" +import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -87,7 +88,7 @@ Review files`, name: "review", template: "Review files", description: "File review", - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), model: { providerID: ProviderV2.ID.make("anthropic"), id: ModelV2.ID.make("claude"), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index ae7322292c..85db0a6bf3 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -142,7 +142,7 @@ describe("Config", () => { // V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated. expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false) expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false) - expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false) + expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false) }), ) @@ -467,14 +467,14 @@ describe("Config", () => { }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { - timeout: { startup: 5000, request: 60000 }, + timeout: { startup: 5000, catalog: 60000, execution: 43200000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: { request: 10000 }, + timeout: { catalog: 10000 }, }, remote: { type: "remote", @@ -552,14 +552,14 @@ describe("Config", () => { }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.mcp).toEqual({ - timeout: { startup: 5000, request: 60000 }, + timeout: { startup: 5000, catalog: 60000, execution: 43200000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: { request: 10000 }, + timeout: { catalog: 10000 }, }, remote: { type: "remote", @@ -792,19 +792,19 @@ describe("Config", () => { buffer: 10000, }) expect(documents[0]?.info.mcp).toMatchObject({ - timeout: { request: 5000 }, + timeout: { catalog: 5000, execution: 5000 }, servers: { local: { type: "local", command: ["node", "server.js"], disabled: true, - timeout: { request: 10000 }, + timeout: { catalog: 10000, execution: 10000 }, }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { client_id: "client", callback_port: 19876 }, - timeout: { request: 20000 }, + timeout: { catalog: 20000, execution: 20000 }, }, }, }) diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts index e26e12bdac..f5e15c2c00 100644 --- a/packages/core/test/config/fixtures/plugin/directory-plugin.ts +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "directory-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts index afa7f5c37e..365d5566d2 100644 --- a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts +++ b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "folder-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 34a69ffbc5..75faeef99d 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { describe, expect } from "bun:test" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" @@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => { it.live("loads user plugins before internal post plugins", () => Effect.gen(function* () { const sdk = yield* SdkPlugins.Service - yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void })) + yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void })) yield* withLocation( { plugins: [ @@ -229,7 +229,7 @@ describe("PluginSupervisor config", () => { const ready = Effect.fnUntraced(function* () { const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush }) function withLocation( @@ -273,9 +273,9 @@ function withLocation( function mutablePlugin(description: string) { const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href return ` -import { define } from ${JSON.stringify(plugin)} +import { Plugin } from ${JSON.stringify(plugin)} -export default define({ +export default Plugin.define({ id: "mutable-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index d1d27dc6e9..0c7c85a1ca 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" @@ -253,7 +254,17 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) - expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.cost).toEqual([ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + tier: undefined, + }, + ]) expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true }) expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.variants?.map((variant) => variant.id)).toEqual([ diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index eda14d14b1..10f02a7342 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import path from "path" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import { eq, inArray, sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { migrations } from "@opencode-ai/core/database/migration.gen" @@ -15,7 +15,11 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" +import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" +import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox" +import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" +import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -23,6 +27,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" @@ -39,6 +44,279 @@ const run = (effect: Effect.Effect) => const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { + test("migrates pre-launch V2 state in place", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`, + ) + yield* db.run( + sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`, + ) + const messages = [ + ["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }], + [ + "msg_shell", + "shell", + { + shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" }, + output: { output: "/tmp", cursor: 4, size: 4, truncated: false }, + time: { created: 2, completed: 3 }, + }, + ], + [ + "msg_assistant", + "assistant", + { + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_old", + name: "read", + provider: "removed", + state: { status: "pending", input: '{"path":"README.md"}', title: "removed" }, + time: { created: 3 }, + }, + ], + time: { created: 3 }, + }, + ], + [ + "msg_failed", + "compaction", + { + status: "failed", + reason: "manual", + summary: "removed", + recent: "removed", + time: { created: 4 }, + }, + ], + [ + "msg_queued", + "compaction", + { status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } }, + ], + [ + "msg_synthetic", + "synthetic", + { sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } }, + ], + [ + "msg_running", + "compaction", + { status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } }, + ], + [ + "msg_completed", + "compaction", + { status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } }, + ], + ] as const + for (const [id, type, data] of messages) + yield* db.run( + sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`, + ) + yield* db.run( + sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`, + ) + yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`) + yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`) + const events = [ + ["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }], + ["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }], + ["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }], + ["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }], + [ + "evt_revert", + 5, + 105, + "session.revert.staged.1", + { + sessionID: "ses_test", + revert: { + messageID: "msg_skill", + snapshot: "tree", + diff: "removed", + files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], + }, + }, + ], + [ + "evt_skill_current", + 6, + 106, + "session.skill.activated.2", + { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, + ], + ] as const + for (const [id, seq, created, type, data] of events) + yield* db.run( + sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`, + ) + + yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration]) + + const rows = yield* db.all<{ + id: string + type: string + seq: number + time_created: number + time_updated: number + data: string + }>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`) + for (const row of rows) + Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type }) + expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true) + expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([ + [ + "msg_assistant", + expect.objectContaining({ + content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })], + }), + ], + ["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })], + [ + "msg_failed", + { + time: { created: 4 }, + status: "failed", + reason: "manual", + error: { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + }, + ], + ["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })], + ["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })], + ["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }], + ["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }], + ]) + expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({ + id: "msg_queued", + session_id: "ses_test", + type: "compaction", + prompt: null, + delivery: null, + admitted_seq: 4, + promoted_seq: null, + time_created: 5, + }) + const migratedEvents = yield* db.all<{ + id: string + aggregate_id: string + seq: number + created: number + type: string + data: string + }>(sql`SELECT * FROM event ORDER BY seq`) + expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([ + { + id: "evt_skill", + aggregate_id: "ses_test", + seq: 1, + created: 101, + type: "session.skill.activated.1", + data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" }, + }, + { + id: "evt_started", + aggregate_id: "ses_test", + seq: 2, + created: 102, + type: "session.compaction.started.1", + data: { sessionID: "ses_test", reason: "auto", recent: "" }, + }, + { + id: "evt_failed", + aggregate_id: "ses_test", + seq: 4, + created: 104, + type: "session.compaction.failed.1", + data: { + sessionID: "ses_test", + reason: "auto", + error: { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + }, + }, + { + id: "evt_revert", + aggregate_id: "ses_test", + seq: 5, + created: 105, + type: "session.revert.staged.1", + data: { + sessionID: "ses_test", + revert: { + messageID: "msg_skill", + snapshot: "tree", + files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], + }, + }, + }, + { + id: "evt_skill_current", + aggregate_id: "ses_test", + seq: 6, + created: 106, + type: "session.skill.activated.1", + data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, + }, + ]) + expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({ + aggregate_id: "ses_test", + seq: 9, + owner_id: "owner", + }) + expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({ + session_id: "ses_test", + baseline: "baseline", + snapshot: '{"source":"value"}', + baseline_seq: 7, + }) + }), + ) + }) + + test("resets incompatible V2 Session event history", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`) + yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`) + yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`) + + yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration]) + + expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined() + expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined() + }), + ) + }) + test("serializes concurrent embedded initialization for one database path", async () => { await using tmp = await tmpdir() const filename = path.join(tmp.path, "embedded.sqlite") @@ -84,13 +362,14 @@ describe("DatabaseMigration", () => { expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( - sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, + sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, ), ).toEqual([ { name: "event_aggregate_seq_idx" }, { name: "event_aggregate_type_seq_idx" }, { name: "session_input_session_admitted_seq_idx" }, - { name: "session_input_session_pending_delivery_seq_idx" }, + { name: "session_input_session_pending_compaction_idx" }, + { name: "session_input_session_pending_type_delivery_seq_idx" }, { name: "session_input_session_promoted_seq_idx" }, { name: "session_message_session_seq_idx" }, { name: "session_message_session_time_created_id_idx" }, @@ -132,6 +411,39 @@ describe("DatabaseMigration", () => { ) }) + test("separates existing fork provenance from subagent hierarchy", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`) + yield* db.run( + sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`) + yield* db.run( + sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration]) + + expect( + yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`), + ).toEqual({ + parent_id: null, + fork_session_id: "ses_source", + fork_message_id: "msg_boundary", + }) + expect( + yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`), + ).toEqual({ + parent_id: null, + fork_session_id: null, + fork_message_id: null, + }) + }), + ) + }) + test("renames instruction state without losing rows or durable updates", async () => { await run( Effect.gen(function* () { @@ -295,7 +607,7 @@ describe("DatabaseMigration", () => { sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, ) yield* db.run( - sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, + sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`, ) yield* db.run( sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, @@ -358,6 +670,37 @@ describe("DatabaseMigration", () => { ) }) + test("preserves admitted prompts while generalizing the durable inbox", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`, + ) + + yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration]) + + expect( + yield* db.all( + sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`, + ), + ).toEqual([ + { + id: "input", + type: "prompt", + prompt: '{"text":"hello"}', + delivery: "steer", + admitted_seq: 4, + promoted_seq: null, + }, + ]) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/fixture/mcp-timeout.ts b/packages/core/test/fixture/mcp-timeout.ts new file mode 100644 index 0000000000..4855b0ec26 --- /dev/null +++ b/packages/core/test/fixture/mcp-timeout.ts @@ -0,0 +1,41 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js" + +const server = new Server( + { name: "timeout", version: "1.0.0" }, + { capabilities: { prompts: {}, resources: {}, tools: {} } }, +) + +server.setRequestHandler(ListToolsRequestSchema, async () => { + if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100) + return { tools: [{ name: "slow", inputSchema: { type: "object" } }] } +}) +server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] })) +server.setRequestHandler(ListResourcesRequestSchema, async () => { + if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100) + return { resources: [{ name: "slow", uri: "test://slow" }] } +}) +server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] })) +server.setRequestHandler(CallToolRequestSchema, async () => { + await Bun.sleep(100) + return { content: [] } +}) +server.setRequestHandler(GetPromptRequestSchema, async () => { + await Bun.sleep(100) + return { messages: [] } +}) +server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + await Bun.sleep(100) + return { contents: [{ uri: request.params.uri, text: "slow" }] } +}) + +await server.connect(new StdioServerTransport()) diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 2c4d1f86a6..b247a4d8b7 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed( instructions: () => Effect.succeed([]), prompts: () => Effect.succeed([]), prompt: () => Effect.succeed(undefined), - resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), + resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })), readResource: () => Effect.succeed(undefined), }), ) -export const emptyConfigLayer = Layer.succeed( - Config.Service, - Config.Service.of({ entries: () => Effect.succeed([]) }), -) +export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) export const testLocationLayer = Layer.succeed( Location.Service, diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index f77e32fd8b..7441ff83db 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -146,7 +146,7 @@ describe("Git trees", () => { RelativePath.make("scope/tracked.txt"), ]) const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 }) - expect(diffs.map((item) => [item.path, item.status])).toEqual([ + expect(diffs.map((item) => [item.file, item.status])).toEqual([ [RelativePath.make("scope/added.txt"), "added"], [RelativePath.make("scope/tracked.txt"), "modified"], ]) @@ -154,7 +154,7 @@ describe("Git trees", () => { const files = new Map([[RelativePath.make("scope/tracked.txt"), before]]) const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 }) expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt")) yield* git.tree.restore({ repository, files }) expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n") expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n") diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 5677e288ef..c09d1e6296 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -4,8 +4,9 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Tool } from "@opencode-ai/core/tool/tool" import { Tools } from "@opencode-ai/core/tool/tools" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, type Scope } from "effect" +import { host } from "../plugin/host" export const toolIdentity = { agent: AgentV2.ID.make("build"), @@ -48,7 +49,7 @@ export const registerToolPlugin = (plugin: { }): Effect.Effect => Effect.gen(function* () { const tools = yield* Tools.Service - const context: Pick = { + const context = host({ tool: { transform: (callback) => Effect.gen(function* () { @@ -66,15 +67,13 @@ export const registerToolPlugin = (plugin: { registrations, (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), { discard: true }, - ) + ).pipe(Effect.orDie) + return { dispose: Effect.void } }), - execute: { - before: () => Effect.die("registerToolPlugin does not support tool hooks"), - after: () => Effect.die("registerToolPlugin does not support tool hooks"), - }, + hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, - } - yield* plugin.effect(context as PluginContext) + }) + yield* plugin.effect(context) }) export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f945cb2265..60e788f7b1 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -3,8 +3,9 @@ import path from "path" import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" -import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Money } from "@opencode-ai/schema/money" +import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -12,6 +13,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" @@ -28,8 +30,305 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) +const itWithSdk = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])), +) describe("LocationServiceMap", () => { + itWithSdk.live("preserves embedded SDK plugins after Location eviction", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const sdk = yield* SdkPlugins.Service + const locations = yield* LocationServiceMap.Service + const id = AgentV2.ID.make("persistent-sdk-agent") + const plugin = EffectPlugin.define({ + id: "persistent-sdk-plugin", + effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), + }) + yield* sdk.register(plugin) + + const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const read = Effect.gen(function* () { + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.flush + const agents = yield* AgentV2.Service + return yield* agents.get(id) + }) + + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + yield* locations.invalidate(ref) + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + }), + ), + ), + ) + + itWithSdk.live("waits for explorer activation to complete", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "blocked-initial-activation", + effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(started) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild, + ) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(flushFiber) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("1 second"), + ) + + const explorer = yield* Effect.gen(function* () { + const agents = yield* AgentV2.Service + return yield* agents.resolve("explore") + }).pipe(Effect.provide(context)) + + expect(explorer).toBeDefined() + expect(explorer?.permissions.length).toBeGreaterThan(0) + }), + ), + ), + ) + + itWithSdk.live("reruns activation for SDK plugins registered during startup", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "fixed-target-first-plugin", + effect: () => + Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(firstStarted) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.yieldNow + yield* sdk.register( + EffectPlugin.define({ + id: "fixed-target-second-plugin", + effect: () => + Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))), + }), + ) + + yield* Deferred.succeed(releaseFirst, undefined) + yield* Deferred.await(secondStarted) + expect(flushFiber.pollUnsafe()).toBeUndefined() + + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(flushFiber) + }), + ), + ), + ) + + itWithSdk.live("reruns activation for Config updates during startup", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const activations = { count: 0 } + const file = path.join(dir.path, "opencode.json") + yield* Effect.promise(() => fs.writeFile(file, "{}")) + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "blocked-config-reload", + effect: () => + Effect.sync(() => ++activations.count).pipe( + Effect.flatMap((activation) => + activation === 1 + ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))) + : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))), + ), + ), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(firstStarted) + + const events = yield* EventV2.Service + const updated = yield* events.subscribe(Config.Event.Updated).pipe( + Stream.filter((event) => event.location?.directory === dir.path), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.promise(() => + fs.writeFile( + file, + JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }), + ), + ) + yield* Fiber.join(updated) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild, + ) + yield* Deferred.succeed(releaseFirst, undefined) + yield* Deferred.await(secondStarted) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(flushFiber) + expect(activations.count).toBe(2) + }), + ), + ), + ) + + itWithSdk.live("keeps flush pending while startup updates continue", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + const events = yield* EventV2.Service + + yield* Effect.forEach( + Array.from({ length: 5 }), + () => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))), + { discard: true }, + ) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Fiber.join(flushFiber) + }), + ), + ), + ) + + itWithSdk.live("keeps flush open while later hot reload runs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + 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)) + + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const completed = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "post-ready-plugin", + effect: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Deferred.succeed(completed, undefined)), + ), + }), + ) + yield* Deferred.await(started) + + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("1 second"), + ) + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(completed) + }), + ), + ), + ) + + itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const completed = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "interrupted-waiter-plugin", + effect: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Deferred.succeed(completed, undefined)), + ), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(started) + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + yield* Fiber.interrupt(flushFiber) + + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(completed) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("500 millis"), + ) + }), + ), + ), + ) + it.live("applies ordered plugin config operations during boot", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -42,7 +341,7 @@ describe("LocationServiceMap", () => { ) const plugins = yield* Effect.gen(function* () { const plugins = yield* PluginV2.Service - yield* (yield* PluginSupervisor.Service).ready + yield* (yield* PluginSupervisor.Service).flush return yield* plugins.list() }).pipe( Effect.scoped, @@ -69,7 +368,7 @@ describe("LocationServiceMap", () => { yield* Effect.gen(function* () { const registry = yield* PluginV2.Service const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] }))) @@ -172,6 +471,36 @@ describe("LocationServiceMap", () => { ), ) + it.live("normalizes ref key shapes to one cached location graph", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const absent = Location.Ref.make({ directory }) + const present = Location.Ref.make({ directory, workspaceID: undefined }) + // The two shapes are not structurally Equal: own-key sets differ. + expect(Object.keys(absent)).toEqual(["directory"]) + expect(Object.keys(present)).toEqual(["directory", "workspaceID"]) + expect(Equal.equals(absent, present)).toBe(false) + + const first = yield* locations.contextEffect(absent) + expect(yield* locations.contextEffect(present)).toBe(first) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1) + + // Invalidating with the shape opposite to the one that booted must evict. + yield* locations.invalidate(present) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0) + }), + ), + ), + ), + ) + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), @@ -224,6 +553,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", @@ -241,6 +571,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", @@ -288,7 +619,7 @@ describe("LocationServiceMap", () => { id: ModelV2.ID.make("chat"), providerID: ProviderV2.ID.make("unavailable"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location, @@ -337,7 +668,7 @@ describe("LocationServiceMap", () => { providerID: ProviderV2.ID.make("aliased"), variant: ModelV2.VariantID.make("high"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location, @@ -366,7 +697,7 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.gen(function* () { const plugins = yield* PluginV2.Service - const reviewer = define({ + const reviewer = EffectPlugin.define({ id: "reviewer", effect: (ctx) => ctx.agent diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 29ac8a96fb..32a0f94d88 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -3,26 +3,165 @@ import { describe, expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js" import { ConfigMCP } from "@opencode-ai/core/config/mcp" +import { Config } from "@opencode-ai/core/config" +import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { MCPClient } from "@opencode-ai/core/mcp/client" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { McpTool } from "@opencode-ai/core/tool/mcp" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { Deferred, Effect, Fiber, Layer, Stream } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" +import { location } from "./fixture/location" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void let calls = 0 +type ResourcePage = { + items: Array<{ name: string; uri: string; description?: string; mimeType?: string }> + nextCursor?: string +} + +type ResourceTemplatePage = { + items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }> + nextCursor?: string +} + +function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) { + return Effect.acquireRelease( + Effect.promise(async () => { + const state = { + resources: [] as ResourcePage["items"], + templates: [] as ResourceTemplatePage["items"], + resourcePages: undefined as Record | undefined, + templatePages: undefined as Record | undefined, + contents: [ + { uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>, + resourceLists: 0, + templateLists: 0, + } + const protocol = new Server( + { name: "mcp-resources", version: "1.0.0" }, + { + capabilities: { + tools: {}, + ...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }), + }, + }, + ) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + if (input.resources !== false) { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + state.resourceLists += 1 + const page = state.resourcePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + state.templateLists += 1 + const page = state.templatePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents })) + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + fetch: (request) => transport.handleRequest(request), + }) + return { + state, + url: http.url.toString(), + sendResourceListChanged: () => protocol.sendResourceListChanged(), + close: async () => { + await protocol.close().catch(() => {}) + await http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +function resourceMcpLayer(url: string) { + const directory = AbsolutePath.make(import.meta.dir) + const unusedIntegration = () => Effect.die("unused integration service") + return MCP.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: new Config.Info({ + mcp: new ConfigMCP.Info({ + servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) }, + }), + }), + }), + ]), + }), + ), + Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), + Layer.mock(EventV2.Service, { + subscribe: () => Stream.never, + publish: (definition, data) => + Effect.succeed({ + id: EventV2.ID.create(), + type: definition.type, + data, + } as EventV2.Payload), + }), + Layer.mock(Form.Service, {}), + Layer.mock(Integration.Service, { + connection: { + active: unusedIntegration, + resolve: unusedIntegration, + key: unusedIntegration, + oauth: unusedIntegration, + update: unusedIntegration, + remove: unusedIntegration, + }, + attempt: { + status: unusedIntegration, + complete: unusedIntegration, + cancel: unusedIntegration, + }, + }), + Layer.mock(Credential.Service, {}), + ), + ), + ) +} + const mcp = Layer.mock(MCP.Service, { tools: () => Effect.succeed([ @@ -71,9 +210,9 @@ const it = testEffect( describe("MCP errors", () => { test("expose useful messages", () => { expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo") - expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe( - "failed", - ) + expect( + new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message, + ).toBe("failed") expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo") expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline") }) @@ -177,6 +316,227 @@ test("retains output schemas across paginated MCP discovery", async () => { ]) }) +test("applies the configured MCP catalog timeout", async () => { + const result = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "catalog-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + environment: { MCP_TIMEOUT_TARGET: "catalog" }, + timeout: new ConfigMCP.Timeout({ catalog: 10 }), + }), + import.meta.dir, + ) + return yield* connection.tools() + }), + ), + ) + + await expect(result).rejects.toThrow("Request timed out") +}) + +test("applies the configured MCP execution timeout", async () => { + const result = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "execution-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.callTool({ name: "slow" }) + }), + ), + ) + + await expect(result).rejects.toThrow("Request timed out") +}) + +test("applies the configured MCP execution timeout to prompts", async () => { + const result = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "prompt-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.prompt({ name: "slow" }) + }), + ), + ) + + await expect(result).rejects.toThrow("Request timed out") +}) + +test("applies configured MCP timeouts to resource operations", async () => { + const catalog = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "resource-catalog-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + environment: { MCP_TIMEOUT_TARGET: "resource-catalog" }, + timeout: new ConfigMCP.Timeout({ catalog: 10 }), + }), + import.meta.dir, + ) + return yield* connection.resources() + }), + ), + ) + await expect(catalog).rejects.toThrow("Request timed out") + + const read = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "resource-read-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.readResource({ uri: "test://slow" }) + }), + ), + ) + await expect(read).rejects.toThrow("Request timed out") +}) + +test("lists, reads, and reports MCP resource changes", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ listChanged: true }) + server.state.resourcePages = { + initial: { + items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }], + nextCursor: "resources-2", + }, + "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] }, + } + server.state.templatePages = { + initial: { + items: [{ name: "File", uriTemplate: "docs://{path}" }], + nextCursor: "templates-2", + }, + "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] }, + } + const connection = yield* MCPClient.connect( + "resources", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + + expect(yield* connection.resources()).toEqual([ + { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined }, + { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" }, + ]) + expect(yield* connection.resourceTemplates()).toEqual([ + { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined }, + { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined }, + ]) + expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({ + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + + const changed = yield* Deferred.make() + connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void)) + yield* Effect.promise(server.sendResourceListChanged) + yield* Deferred.await(changed) + }), + ), + ) +}) + +test("skips MCP resource requests when the capability is absent", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ resources: false }) + const connection = yield* MCPClient.connect( + "resources", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + expect(yield* connection.resources()).toEqual([]) + expect(yield* connection.resourceTemplates()).toEqual([]) + expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined() + expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({ + resources: 0, + templates: 0, + }) + }), + ), + ) +}) + +test("loads and reads MCP resources", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer() + server.state.resources = [{ name: "Readme", uri: "docs://readme" }] + server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }] + + yield* Effect.gen(function* () { + const service = yield* MCP.Service + expect(yield* service.resourceCatalog()).toEqual({ + resources: [ + { + server: "resources", + name: "Readme", + uri: "docs://readme", + description: undefined, + mimeType: undefined, + }, + ], + templates: [ + { + server: "resources", + name: "File", + uriTemplate: "docs://{path}", + description: undefined, + mimeType: undefined, + }, + ], + }) + + server.state.resources = [{ name: "Guide", uri: "docs://guide" }] + expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"]) + expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({ + server: "resources", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + }).pipe(Effect.provide(resourceMcpLayer(server.url))) + }), + ), + ) +}) + it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service @@ -231,7 +591,7 @@ it.effect("does not call MCP when permission is blocked", () => Effect.gen(function* () { calls = 0 assertion = yield* Deferred.make() - decision = Effect.fail(new PermissionV2.BlockedError({ rules: [] })) + decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] })) const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts new file mode 100644 index 0000000000..8927df9a05 --- /dev/null +++ b/packages/core/test/plugin-hooks.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { Message, SystemPart } from "@opencode-ai/llm" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { Session } from "@opencode-ai/schema/session" +import { Effect, Layer } from "effect" +import { PluginHooks } from "../src/plugin/hooks" + +describe("PluginHooks", () => { + it("registers scoped domain hooks and triggers them sequentially", async () => { + const seen: string[] = [] + const program = Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "request", (event) => + Effect.sync(() => { + seen.push("first") + event.system.push(SystemPart.make("second")) + }), + ) + yield* hooks.register("session", "request", (event) => + Effect.sync(() => { + seen.push(event.system[1]?.text ?? "missing") + event.messages = [Message.user("changed")] + }), + ) + const event = { + sessionID: Session.ID.make("ses_hooks"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + system: [SystemPart.make("first")], + messages: [Message.user("original")], + tools: {}, + } + + expect(yield* hooks.trigger("session", "request", event)).toBe(event) + expect(seen).toEqual(["first", "second"]) + expect(event.messages).toEqual([Message.user("changed")]) + }) + + await Effect.runPromise( + Effect.scoped(program).pipe( + Effect.provide(PluginHooks.node.implementation as Layer.Layer), + ), + ) + }) +}) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 5d87102b7d..79fcc5d0f6 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" @@ -49,7 +49,7 @@ describe("PluginV2", () => { .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) const managed = () => - define({ + EffectPlugin.define({ id: "managed", effect: (ctx) => ctx.agent @@ -97,25 +97,40 @@ describe("PluginV2", () => { }), ) - it.effect("retries the same generation after materialization fails", () => + it.effect("skips failed plugins and loads the rest", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service let fail = true - const plugin = define({ - id: "retry", + const good = EffectPlugin.define({ + id: "good", effect: (ctx) => ctx.agent - .transform(() => { - if (fail) throw new Error("materialization failed") - }) + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = "loaded" + }), + ) .pipe(Effect.asVoid), }) + const bad = EffectPlugin.define({ + id: "bad", + effect: () => { + if (fail) return Effect.die(new Error("materialization failed")) + return Effect.void + }, + }) + + yield* plugins.activate([{ plugin: good }, { plugin: bad }]) + expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded") - expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true) fail = false - yield* plugins.activate([{ plugin }]) - - expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }]) + yield* plugins.activate([{ plugin: good }, { plugin: bad }]) + expect(yield* plugins.list()).toEqual([ + { id: Plugin.ID.make("good") }, + { id: Plugin.ID.make("bad") }, + ]) }), ) @@ -142,7 +157,7 @@ describe("PluginV2", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service let visible = true - const plugin = define({ + const plugin = EffectPlugin.define({ id: "isolated", effect: () => Effect.serviceOption(Secret).pipe( @@ -161,7 +176,7 @@ describe("PluginV2", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service - const plugin = define({ + const plugin = EffectPlugin.define({ id: "tool-plugin", effect: (ctx) => ctx.tool @@ -202,7 +217,7 @@ describe("PluginV2", () => { output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), }) - const plugin = define({ + const plugin = EffectPlugin.define({ id: "grouped-tools", effect: (ctx) => ctx.tool @@ -234,7 +249,7 @@ describe("PluginV2", () => { after?: { input: unknown; result: unknown; output: unknown } } = {} - const plugin = define({ + const plugin = EffectPlugin.define({ id: "tool-hooks", effect: (ctx) => Effect.gen(function* () { @@ -252,19 +267,23 @@ describe("PluginV2", () => { ) .pipe(Effect.orDie) - yield* ctx.tool.execute - .before((event) => { - seen.before = event.input - event.input = { text: "before-mutated" } - }) + yield* ctx.tool + .hook("execute.before", (event) => + Effect.sync(() => { + seen.before = event.input + event.input = { text: "before-mutated" } + }), + ) .pipe(Effect.asVoid) - yield* ctx.tool.execute - .after((event) => { - seen.after = { input: event.input, result: event.result, output: event.output } - event.result = { type: "text", value: "after-mutated" } - event.output = { structured: { rewritten: true }, content: [] } - }) + yield* ctx.tool + .hook("execute.after", (event) => + Effect.sync(() => { + seen.after = { input: event.input, result: event.result, output: event.output } + event.result = { type: "text", value: "after-mutated" } + event.output = { structured: { rewritten: true }, content: [] } + }), + ) .pipe(Effect.asVoid) }), }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ea6e65bb64..ffef32738d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" @@ -46,6 +47,7 @@ export const PluginTestLayer = AppNodeBuilder.build( CommandV2.node, Integration.node, PluginRuntime.node, + PluginHooks.node, Reference.node, SkillV2.node, ToolHooks.node, diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts index a5f12a113d..e607b658e3 100644 --- a/packages/core/test/plugin/fixtures/config-effect-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -1,7 +1,7 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "config-effect-plugin", effect: (ctx) => ctx.agent diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index ed53e4b947..0fef8fbc65 100644 --- a/packages/core/test/plugin/fixtures/config-promise-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "config-promise-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/plugin/fixtures/failing-plugin.ts b/packages/core/test/plugin/fixtures/failing-plugin.ts index 4daac0acd0..dac49b410d 100644 --- a/packages/core/test/plugin/fixtures/failing-plugin.ts +++ b/packages/core/test/plugin/fixtures/failing-plugin.ts @@ -1,7 +1,7 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "failing-plugin", effect: () => Effect.die("plugin failed"), }) diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts index f799c96888..5dae699f6e 100644 --- a/packages/core/test/plugin/fixtures/variant-source-plugin.ts +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -1,8 +1,8 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "variant-source", effect: (ctx) => ctx.catalog diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 2aa8789548..659ec40f86 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,4 +1,4 @@ -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused agent.reload"), }, aisdk: overrides.aisdk ?? { - sdk: () => Effect.die("unused aisdk.sdk"), - language: () => Effect.die("unused aisdk.language"), + hook: () => Effect.die("unused aisdk.hook"), }, catalog: overrides.catalog ?? { provider: { @@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext { integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), - connectKey: () => Effect.die("unused integration.connectKey"), - connectOauth: () => Effect.die("unused integration.connectOauth"), - attemptStatus: () => Effect.die("unused integration.attemptStatus"), - attemptComplete: () => Effect.die("unused integration.attemptComplete"), - attemptCancel: () => Effect.die("unused integration.attemptCancel"), + connect: { + key: () => Effect.die("unused integration.connect.key"), + oauth: () => Effect.die("unused integration.connect.oauth"), + }, + attempt: { + status: () => Effect.die("unused integration.attempt.status"), + complete: () => Effect.die("unused integration.attempt.complete"), + cancel: () => Effect.die("unused integration.attempt.cancel"), + }, transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext { }, tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), - execute: { - before: () => Effect.die("unused tool.execute.before"), - after: () => Effect.die("unused tool.execute.after"), - }, + hook: () => Effect.die("unused tool.hook"), }, session: overrides.session ?? { create: () => Effect.die("unused session.create"), @@ -83,6 +83,9 @@ export function host(overrides: Overrides = {}): PluginContext { prompt: () => Effect.die("unused session.prompt"), command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), + // Plugins register session hooks during setup, so a bare host accepts the + // registration; the callback only runs when a test triggers the request pipeline. + hook: () => Effect.succeed({ dispose: Effect.void }), }, } } @@ -188,11 +191,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte return { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), - connectKey: () => Effect.die("unused integration.connectKey"), - connectOauth: () => Effect.die("unused integration.connectOauth"), - attemptStatus: () => Effect.die("unused integration.attemptStatus"), - attemptComplete: () => Effect.die("unused integration.attemptComplete"), - attemptCancel: () => Effect.die("unused integration.attemptCancel"), + connect: { + key: () => Effect.die("unused integration.connect.key"), + oauth: () => Effect.die("unused integration.connect.oauth"), + }, + attempt: { + status: () => Effect.die("unused integration.attempt.status"), + complete: () => Effect.die("unused integration.attempt.complete"), + cancel: () => Effect.die("unused integration.attempt.cancel"), + }, reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 545e09e0c3..c336666c86 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,5 +1,6 @@ import path from "path" import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" @@ -51,23 +52,31 @@ describe("ModelsDevPlugin", () => { temperature: true, tool_call: true, cost: { - input: 2.5, - output: 15, + input: Money.USDPerMillionTokens.make(2.5), + output: Money.USDPerMillionTokens.make(15), tiers: [ { tier: { type: "context", size: 272_000 }, - input: 3, - output: 18, - cache_read: 0.25, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache_read: Money.USDPerMillionTokens.make(0.25), }, ], - context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + context_over_200k: { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache_read: Money.USDPerMillionTokens.make(0.5), + }, }, limit: { context: 1_050_000, input: 922_000, output: 128_000 }, experimental: { modes: { fast: { - cost: { input: 5, output: 30, cache_read: 0.5 }, + cost: { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache_read: Money.USDPerMillionTokens.make(0.5), + }, provider: { headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -107,18 +116,31 @@ describe("ModelsDevPlugin", () => { variants: [], }) expect(fast?.cost).toEqual([ - { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, + }, { tier: { type: "context", size: 272_000 }, - input: 3, - output: 18, - cache: { read: 0.25, write: 0 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache: { + read: Money.USDPerMillionTokens.make(0.25), + write: Money.USDPerMillionTokens.zero, + }, }, { tier: { type: "context", size: 200_000 }, - input: 5, - output: 22.5, - cache: { read: 0.5, write: 0 }, + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, }, ]) }), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index fbe4029c3a..05a20693b5 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -16,7 +16,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) const seen: string[] = [] - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-client-reads", setup: async (ctx) => { const results = await Promise.all([ @@ -46,7 +46,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-example", setup: async (ctx) => { expect(ctx.options.mode).toBe("strict") @@ -75,7 +75,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-dispose", setup: async (ctx) => { const registration = await ctx.agent.transform((draft) => { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index f20091d514..13109550c7 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,4 +1,5 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { Money } from "@opencode-ai/schema/money" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -185,7 +186,16 @@ describe("OpenAIPlugin", () => { draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => { - model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.zero, + }, + }, + ] }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index c8b3757d3d..f8334026f9 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -65,7 +66,16 @@ function withEnv(vars: Record, effect: () = ) } -const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] +const cost = (input: number, output = 0) => [ + { + input: Money.USDPerMillionTokens.make(input), + output: Money.USDPerMillionTokens.make(output), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, +] describe("OpencodePlugin", () => { it.effect("registers account and service account methods", () => diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 1a2ba5009d..de0f745183 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -37,17 +37,19 @@ describe("SkillPlugin.Plugin", () => { Effect.provide(NodeFileSystem.layer), ) const skills = yield* skill.list() - const report = skills.find((item) => item.name === "report") + const report = skills.find((item) => item.id === "report") expect(skills).toContainEqual( expect.objectContaining({ - name: "customize-opencode", - description: expect.stringContaining("opencode's own configuration"), + id: "opencode", + name: "OpenCode", + description: expect.stringContaining("any question about OpenCode itself"), }), ) expect(skills).toContainEqual( expect.objectContaining({ - name: "report", + id: "report", + name: "Report", description: expect.stringContaining("opencode issue"), }), ) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index df63072fb9..703b098f63 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -75,7 +76,7 @@ const it = testEffect( ) describe("SessionV2.compact", () => { - it.effect("manually compacts the active session context", () => + it.effect("durably admits and coalesces manual compaction", () => Effect.gen(function* () { requests = [] const session = yield* SessionV2.Service @@ -95,13 +96,19 @@ describe("SessionV2.compact", () => { inputID: messageID, }) - yield* session.compact({ sessionID: created.id }) + expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({ + _tag: "Session.CompactionConflictError", + inputID: messageID, + }) + const first = yield* session.compact({ sessionID: created.id }) + const second = yield* session.compact({ sessionID: created.id }) - expect(requests).toHaveLength(1) - expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.") - expect(yield* session.context(created.id)).toMatchObject([ - { type: "compaction", reason: "manual", summary: "manual session summary", recent: "" }, - ]) + expect(second.id).toBe(first.id) + expect(requests).toHaveLength(0) + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({ + id: first.id, + }) + expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined() }), ) }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index dd5e9fb749..e8fb3addec 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" -import { DateTime, Effect, Layer, Stream } from "effect" +import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -52,6 +52,15 @@ const it = testEffect( ), ) +test("compaction prompt preserves detailed work state and relevant files", () => { + const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] }) + + expect(prompt).toContain("## Work State\n### Completed") + expect(prompt).toContain("### Active") + expect(prompt).toContain("### Blocked") + expect(prompt).toContain("## Relevant Files") +}) + test("compaction describes tool media without embedding base64", () => { const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const serialized = SessionCompaction.serializeToolContent([ @@ -68,11 +77,31 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).not.toContain(base64) }) +test("compaction prompt requires the checkpoint headings in order", () => { + const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) + expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([ + "## Objective", + "## Important Details", + "## Work State", + "### Completed", + "### Active", + "### Blocked", + "## Next Move", + "## Relevant Files", + ]) + expect(prompt).toContain("one or two brief sentences") + expect(prompt).toContain("constraints/preferences, decisions and why") + expect(prompt).toContain("immediate concrete action") + expect(prompt).toContain("next action if known") + expect(prompt).toContain("Keep every section, even when empty.") +}) + it.effect("manual compaction summarizes short context instead of no-op", () => Effect.gen(function* () { requests = [] const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service + const events = yield* EventV2.Service const store = yield* SessionStore.Service const sessionID = SessionV2.ID.make("ses_manual_compaction") const userMessage = { @@ -108,7 +137,18 @@ it.effect("manual compaction summarizes short context instead of no-op", () => ), ) - expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true) + const delta = yield* events + .subscribe(SessionEvent.Compaction.Delta) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + expect( + yield* compaction.compactManual({ + session, + messages: [userMessage], + inputID: SessionMessage.ID.make("msg_manual_compaction"), + }), + ).toBe(true) + expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"]) expect(requests).toHaveLength(1) expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index fc3b4d127d..3c4bb65a44 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import { DateTime, Effect, Layer, Stream } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AgentV2 } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" @@ -206,10 +207,11 @@ describe("SessionV2.create", () => { const forkContext = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) - expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" }) + expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } }) + expect(forked.parentID).toBeUndefined() expect(forkContext).toMatchObject([ { type: "user", text: "First" }, - { type: "synthetic", text: "parent note", sessionID: forked.id }, + { type: "synthetic", text: "parent note" }, ]) expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(history).toHaveLength(1) @@ -224,9 +226,17 @@ describe("SessionV2.create", () => { promotedSeq: 2, }) - yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false }) + yield* session.prompt({ + sessionID: parent.id, + prompt: PromptInput.Prompt.make({ text: "Parent changed" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false }) + yield* session.prompt({ + sessionID: forked.id, + prompt: PromptInput.Prompt.make({ text: "Child continues" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, forked.id) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) @@ -259,14 +269,39 @@ describe("SessionV2.create", () => { resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) + const assistantMessageID = SessionMessage.ID.create() + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) + yield* events.publish(SessionEvent.Step.Started, { + sessionID: parent.id, + assistantMessageID, + agent: AgentV2.ID.make("build"), + model, + }) + yield* events.publish(SessionEvent.Step.Ended, { + sessionID: parent.id, + assistantMessageID, + finish: "stop", + cost: Money.USD.make(0.75), + tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, + }) const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) + const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id }) + const complete = yield* session.fork({ sessionID: parent.id }) const context = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) + expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id }) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) expect(history[0]).toMatchObject({ data: { from: second.id } }) + expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) + expect(yield* session.context(beforeFirst.id)).toEqual([]) + expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) + expect(complete).toMatchObject({ + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) }), ) @@ -373,7 +408,11 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false }) + yield* session.prompt({ + sessionID: created.id, + prompt: PromptInput.Prompt.make({ text: "Hello" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, created.id) expect( @@ -495,7 +534,7 @@ describe("SessionV2.create", () => { const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } }) + expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 }) expect(shell?.output?.output).toContain("hello") expect(shell?.output?.truncated).toBe(false) expect(shell?.time.completed).toBeDefined() @@ -515,8 +554,8 @@ describe("SessionV2.create", () => { const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } }) - expect(shell?.shell.exit).not.toBe(0) + expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" }) + expect(shell?.exit).not.toBe(0) expect(shell?.time.completed).toBeDefined() }), ), @@ -527,7 +566,7 @@ describe("SessionV2.create", () => { const session = yield* SessionV2.Service const created = yield* session.create({ location }) - yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect( @@ -542,7 +581,7 @@ describe("SessionV2.create", () => { const missing = SessionV2.ID.make("ses_missing_agent_switch") expect( - yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe( Effect.flip, Effect.map((error) => error._tag), ), diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts new file mode 100644 index 0000000000..4ae6196272 --- /dev/null +++ b/packages/core/test/session-error.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { + AuthenticationReason, + ContentPolicyReason, + InvalidProviderOutputReason, + InvalidRequestReason, + LLMError, + NoRouteReason, + ModelID, + ProviderID, + ProviderInternalReason, + QuotaExceededReason, + RateLimitReason, + TransportReason, + UnknownProviderReason, + ToolFailure, +} from "@opencode-ai/llm" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { toSessionError } from "@opencode-ai/core/session/to-session-error" +import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" + +const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason }) + +describe("toSessionError", () => { + test("maps every LLM reason to the open wire type", () => { + expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({ + type: "provider.rate-limit", + message: "rate", + }) + expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe( + "provider.auth", + ) + expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota") + expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter") + expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport") + expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe( + "provider.internal", + ) + expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe( + "provider.invalid-output", + ) + expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request") + expect( + toSessionError( + llm( + new NoRouteReason({ + route: "route", + provider: ProviderID.make("provider"), + model: ModelID.make("model"), + }), + ), + ).type, + ).toBe("provider.no-route") + expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown") + }) + + test("preserves the permission rejection type without exposing internal fields", () => { + const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] }) + expect(toSessionError(blocked)).toEqual({ + type: "permission.rejected", + message: "Permission denied: external_directory", + }) + expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({ + type: "permission.rejected", + message: "Permission denied: external_directory", + }) + }) + + test("retries only rate limits, provider-internal failures, and transport failures", () => { + const eligible = [ + llm(new RateLimitReason({ message: "rate" })), + llm(new ProviderInternalReason({ message: "internal", status: 500 })), + llm(new TransportReason({ message: "transport" })), + ] + const ineligible = [ + llm(new AuthenticationReason({ message: "auth", kind: "invalid" })), + llm(new QuotaExceededReason({ message: "quota" })), + llm(new ContentPolicyReason({ message: "blocked" })), + llm(new InvalidProviderOutputReason({ message: "output" })), + llm(new InvalidRequestReason({ message: "request" })), + llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })), + llm(new UnknownProviderReason({ message: "unknown" })), + ] + + expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true]) + expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false]) + }) +}) diff --git a/packages/core/test/session-execution-local.test.ts b/packages/core/test/session-execution-local.test.ts new file mode 100644 index 0000000000..be1c4aebea --- /dev/null +++ b/packages/core/test/session-execution-local.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { LLMError, TransportReason } from "@opencode-ai/llm" +import { terminal } from "@opencode-ai/core/session/execution/local" +import { UserInterruptedError } from "@opencode-ai/core/session/error" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Effect, Exit } from "effect" + +describe("SessionExecutionLocal lifecycle", () => { + test("classifies success and typed failure terminals", () => { + expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" }) + expect( + terminal( + Exit.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new TransportReason({ message: "Disconnected" }), + }), + ), + ), + ).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } }) + const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") }) + expect(terminal(Exit.fail(storage))).toEqual({ + type: "failed", + error: { type: "unknown", message: storage.message }, + }) + }) + + test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => { + const interrupted = Effect.runSyncExit(Effect.interrupt) + expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" }) + expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" }) + expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" }) + expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" }) + }) +}) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 854cd73d4b..34439836a4 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -303,7 +303,6 @@ describe("SessionInstructions", () => { const synthetic = SessionMessage.Synthetic.make({ id: SessionMessage.ID.make("msg_synthetic"), type: "synthetic", - sessionID: SessionV2.ID.make("ses_test"), text: "Instructions from: /repo/sub/AGENTS.md\ncontent", description: "Loaded /repo/sub/AGENTS.md", metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } }, diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index ac2fe59496..a6c7797e0f 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -87,11 +88,11 @@ describe("SessionV2.log", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const created = yield* session.create({ location }) - yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") }) // Not in the durable manifest, so reads must skip it without failing. yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) - yield* session.switchAgent({ sessionID: created.id, agent: "two" }) - yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") }) const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 }))) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 77dd272b12..1eb7f6b6fd 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Schema } from "effect" -import { asc, eq } from "drizzle-orm" +import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect" +import { asc, eq, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" @@ -15,9 +16,11 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/schema/prompt" +import { Money } from "@opencode-ai/schema/money" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { fromRow } from "@opencode-ai/core/session/info" import { SessionInput } from "@opencode-ai/core/session/input" import { Shell } from "@opencode-ai/schema/shell" import { @@ -35,23 +38,27 @@ const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") } -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) +const build = AgentV2.defaultID const assistantRow = ( id: SessionMessage.ID, seq: number, time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created }, + usage?: Pick, ) => { const { id: _, type, ...data - } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) + } = encodeMessage( + SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }), + ) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } describe("SessionProjector", () => { - it.effect("projects staged, cleared, and committed reverts", () => + it.effect("does not settle a pending manual compaction on an auto failure", () => Effect.gen(function* () { const db = (yield* Database.Service).db yield* db @@ -69,14 +76,132 @@ describe("SessionProjector", () => { version: "test", }) .run() + const events = yield* EventV2.Service + const inputID = SessionMessage.ID.make("msg_manual_compaction") + yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID }) + + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "auto", + error: { type: "compaction.failed", message: "Auto compaction failed" }, + }) + + expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID }) + }), + ) + + it.effect("loads legacy revert storage into canonical state", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const legacy = JSON.stringify({ + messageID: "msg_boundary", + snapshot: "tree", + diff: "legacy patch", + files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`) + const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get() + if (!stored) return yield* Effect.die("Session row missing") + const storedRevert = fromRow(stored).revert + expect(String(storedRevert?.messageID)).toBe("msg_boundary") + expect(String(storedRevert?.snapshot)).toBe("tree") + expect(storedRevert?.files).toEqual([ + { file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }, + ]) + }), + ) + + it.effect("folds live compaction deltas into running memory state", () => + Effect.gen(function* () { + const state = { + messages: [ + SessionMessage.CompactionRunning.make({ + id: SessionMessage.ID.make("msg_compaction"), + type: "compaction", + status: "running", + reason: "manual", + summary: "partial ", + recent: "recent", + time: { created }, + }), + ], + } + yield* SessionMessageUpdater.update( + SessionMessageUpdater.memory(state), + SessionEvent.Compaction.Delta.make({ + id: EventV2.ID.make("evt_delta"), + type: "session.compaction.delta", + created, + data: { sessionID, text: "summary" }, + }), + ) + expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" }) + }), + ) + + it.effect("projects staged, cleared, and committed reverts", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) + .run() const boundary = SessionMessage.ID.make("msg_boundary") const earlier = SessionMessage.ID.make("msg_earlier") yield* db .insert(SessionMessageTable) .values([ assistantRow(earlier, 0), - assistantRow(boundary, 1), - assistantRow(SessionMessage.ID.make("msg_later"), 2), + assistantRow( + boundary, + 1, + { created }, + { + cost: Money.USD.make(0.5), + tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + }, + ), + assistantRow( + SessionMessage.ID.make("msg_later"), + 2, + { created }, + { + cost: Money.USD.make(0.75), + tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, + }, + ), ]) .run() yield* db @@ -86,7 +211,7 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, + revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] }, }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ messageID: boundary, @@ -101,11 +226,19 @@ describe("SessionProjector", () => { }) yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, - messageID: boundary, + to: boundary, }) expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([earlier]) + expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({ + cost: Money.USD.make(1.25), + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) // A committed revert resets the context checkpoint so the next turn re-initializes. expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), @@ -252,7 +385,7 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.AgentSelected, { sessionID, - agent: "build", + agent: build, }) yield* events.publish(SessionEvent.ModelSelected, { sessionID, @@ -294,6 +427,7 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "recent context", }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, @@ -303,18 +437,18 @@ describe("SessionProjector", () => { yield* db .select({ id: EventTable.id }) .from(EventTable) - .where(eq(EventTable.type, SessionEvent.Compaction.Delta.type)) + .where(sql`${EventTable.type} like 'session.compaction.delta.%'`) .all() .pipe(Effect.orDie), - ).toEqual([]) + ).toHaveLength(0) expect( yield* db - .select({ id: SessionMessageTable.id }) + .select({ data: SessionMessageTable.data }) .from(SessionMessageTable) .where(eq(SessionMessageTable.type, "compaction")) .all() .pipe(Effect.orDie), - ).toEqual([]) + ).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }]) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", @@ -330,7 +464,7 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages.map((message) => message.type)).toEqual([ @@ -346,7 +480,9 @@ describe("SessionProjector", () => { }) expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ - shell: { command: "pwd", status: "exited", exit: 0 }, + command: "pwd", + status: "exited", + exit: 0, output: { output: "/project", truncated: false }, time: { completed: DateTime.makeUnsafe(0) }, }) @@ -386,11 +522,7 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_creator_collision") - const { - id: _, - type, - ...data - } = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } }) + const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } }) yield* db .insert(SessionMessageTable) .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data }) @@ -400,7 +532,7 @@ describe("SessionProjector", () => { .publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: id, - agent: "build", + agent: build, model, }) .pipe(Effect.exit) @@ -417,7 +549,7 @@ describe("SessionProjector", () => { const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created }, @@ -425,7 +557,7 @@ describe("SessionProjector", () => { const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, @@ -437,6 +569,73 @@ describe("SessionProjector", () => { }), ) + it.effect("projects retry state and clears it at the next step or execution terminal", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const first = SessionMessage.ID.make("msg_retry_first") + const second = SessionMessage.ID.make("msg_retry_second") + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model }) + yield* events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: first, + attempt: 2, + at: 2_000, + error: { type: "provider.transport", message: "Disconnected" }, + }) + + const decode = (row: typeof SessionMessageTable.$inferSelect) => + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }) + const firstRow = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, first)) + .get() + .pipe(Effect.orDie) + const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection"))) + expect(decode(projected)).toMatchObject({ + retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } }, + }) + + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model }) + yield* events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: second, + attempt: 3, + at: 6_000, + error: { type: "provider.internal", message: "Unavailable" }, + }) + yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" }) + + const rows = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie) + expect(decode(rows[0])).not.toHaveProperty("retry") + expect(decode(rows[1])).not.toHaveProperty("retry") + }), + ) + it.effect("updates only the newest incomplete assistant projection", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -467,12 +666,15 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const service = yield* EventV2.Service + const usageUpdated = yield* service + .subscribe(SessionEvent.UsageUpdated) + .pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true })) yield* service.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: Money.USD.make(1.25), + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }) const rows = yield* db @@ -483,14 +685,31 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages[0]).not.toHaveProperty("time.completed") expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", + cost: Money.USD.make(1.25), + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, time: { completed: DateTime.makeUnsafe(0) }, }) + expect( + yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie), + ).toMatchObject({ + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) + expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({ + sessionID, + cost: Money.USD.make(1.25), + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }) }), ) @@ -530,7 +749,7 @@ describe("SessionProjector", () => { yield* service.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), - textID: "text-stale", + ordinal: 0, }) const rows = yield* db @@ -541,21 +760,21 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", - agent: "build", + agent: build, model, - content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], + content: [SessionMessage.AssistantText.make({ type: "text", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created }, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 8250d876d2..34b1ca1487 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -6,6 +6,7 @@ import path from "path" import { pathToFileURL } from "url" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -105,7 +106,7 @@ const eventCount = (type: string) => ), ) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) const assistantRow = (id: SessionMessage.ID, seq: number) => { const { id: _, @@ -115,7 +116,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => { SessionMessage.Assistant.make({ id, type: "assistant", - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [], time: { created: DateTime.makeUnsafe(0) }, @@ -246,7 +247,9 @@ describe("SessionV2.prompt", () => { mention: { start: 8, end: 17, text: "[Image 1]" }, }, ]) - expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + const stored = yield* admitted(message.id) + expect(stored?.type).toBe("prompt") + if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files) }), ) @@ -275,31 +278,35 @@ describe("SessionV2.prompt", () => { source: { type: "uri", uri: sourceUri.href }, name: "main.ts", }) - expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8").replace(/\r$/, "")).toBe( - 'import { describe, expect } from "bun:test"', - ) + expect( + Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64") + .toString("utf8") + .replace(/\r$/, ""), + ).toBe('import { describe, expect } from "bun:test"') }), ) - it.effect("rejects directories as file attachments", () => + it.effect("materializes directories as directory attachments", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const uri = pathToFileURL(import.meta.dir).href - const error = yield* session - .prompt({ - sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, - resume: false, - }) - .pipe(Effect.flip) - - expect(error).toMatchObject({ - _tag: "Session.AttachmentError", - uri, - message: `Attachment is not a file: ${uri}`, + const message = yield* session.prompt({ + sessionID, + prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, + resume: false, }) + + expect(message.prompt.files).toHaveLength(1) + expect(message.prompt.files?.[0]).toMatchObject({ + mime: "application/x-directory", + source: { type: "uri", uri }, + name: "source", + }) + expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain( + "session-prompt.test.ts", + ) }), ) @@ -332,7 +339,8 @@ describe("SessionV2.prompt", () => { name: "image.png", }, ]) - expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + const stored = yield* admitted(message.id) + expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files) }), ) @@ -565,7 +573,12 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false }) + yield* session.prompt({ + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "Promote once" }), + resume: false, + }) yield* Effect.all( [SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)], @@ -665,7 +678,6 @@ describe("SessionV2.prompt", () => { ...data } = encodeMessage({ id: messageID, - sessionID, type: "synthetic", text: "Existing history", time: { created: DateTime.makeUnsafe(0) }, @@ -677,7 +689,12 @@ describe("SessionV2.prompt", () => { .pipe(Effect.orDie) const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false }) + .prompt({ + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), + resume: false, + }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts new file mode 100644 index 0000000000..b0fa62e5a8 --- /dev/null +++ b/packages/core/test/session-remove.test.ts @@ -0,0 +1,62 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + list: () => Effect.succeed([]), + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), +) +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) + +describe("SessionV2.remove", () => { + it.effect("removes a session and its children", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const parent = yield* session.create({ location }) + const child = yield* session.create({ parentID: parent.id }) + + yield* session.remove(parent.id) + + expect((yield* session.list()).data).toEqual([]) + expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" }) + expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" }) + }), + ) + + it.effect("fails when the session does not exist", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const sessionID = SessionV2.ID.make("ses_missing") + + expect(yield* Effect.result(session.remove(sessionID))).toMatchObject({ + _tag: "Failure", + failure: { _tag: "Session.NotFoundError", sessionID }, + }) + }), + ) +}) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index ecc4e613ac..c566c3f33c 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => { Effect.gen(function* () { const failure = new Error("failed") const defect = new Error("defect") + const settled: Exit.Exit[] = [] const coordinator = yield* SessionRunCoordinator.make({ drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)), + settled: (_key, exit) => Effect.sync(() => void settled.push(exit)), }) const failed = yield* coordinator.run("failure").pipe(Effect.exit) @@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => { const died = yield* coordinator.run("defect").pipe(Effect.exit) expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue() expect(Array.from(yield* coordinator.active)).toEqual([]) + expect(settled).toHaveLength(2) + }), + ), + ) + + it.effect("preserves settlement hook defects while releasing ownership", () => + Effect.scoped( + Effect.gen(function* () { + const defect = new Error("terminal publication failed") + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Effect.void, + settled: () => Effect.die(defect), + }) + + const exit = yield* coordinator.run("session").pipe(Effect.exit) + + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect) + expect(yield* coordinator.active).toEqual(new Set()) }), ), ) @@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => { it.effect("does nothing when interrupted while idle", () => Effect.scoped( Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) - yield* coordinator.interrupt("session") + const reasons: Array = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Effect.void, + settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)), + }) + yield* coordinator.interrupt("session", "user") + yield* coordinator.run("session") + expect(reasons).toEqual([undefined]) + }), + ), + ) + + it.effect("does not attach a late interrupt reason after terminal settlement starts", () => + Effect.scoped( + Effect.gen(function* () { + const settling = yield* Deferred.make() + const release = yield* Deferred.make() + const reasons: Array = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Effect.void, + settled: (_key, _exit, reason) => + Deferred.succeed(settling, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => void reasons.push(reason))), + ), + }) + + const run = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.await(settling) + yield* coordinator.interrupt("session", "user") + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(run) + yield* coordinator.run("session") + + expect(reasons).toEqual([undefined, undefined]) }), ), ) @@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => { const started = yield* Deferred.make() const interrupted = yield* Deferred.make() let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ + const reasons: Array = [] + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.andThen(Deferred.succeed(started, undefined)), Effect.andThen(Effect.never), Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), ), + settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)), }) const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Deferred.await(started) yield* coordinator.wake("session") - yield* coordinator.interrupt("session") + yield* coordinator.interrupt("session", "user") yield* Deferred.await(interrupted) const exit = yield* Fiber.await(resumed) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(Array.from(yield* coordinator.active)).toEqual([]) expect(runs).toBe(1) + expect(reasons).toEqual(["user"]) }), ), ) @@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => { const cleanupGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() let runs = 0 + let starts = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( @@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => { : Deferred.succeed(secondStarted, undefined), ), ), + started: () => Effect.sync(() => starts++).pipe(Effect.asVoid), }) yield* coordinator.wake("session") @@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => { yield* Deferred.await(secondStarted) expect(runs).toBe(2) + expect(starts).toBe(2) }), ), ) @@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => { const gate = yield* Deferred.make() const idle = yield* Deferred.make() let drains = 0 + let starts = 0 const settled: Exit.Exit[] = [] const coordinator = yield* SessionRunCoordinator.make({ drain: () => @@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => { ), Effect.asVoid, ), + started: () => Effect.sync(() => starts++).pipe(Effect.asVoid), settled: (_key, exit) => Effect.sync(() => void settled.push(exit)).pipe( Effect.andThen(Deferred.succeed(idle, undefined)), @@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => { yield* Deferred.await(idle) expect(drains).toBe(2) + expect(starts).toBe(1) expect(settled).toHaveLength(1) expect(Exit.isSuccess(settled[0]!)).toBe(true) }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index f415614cf5..396b833a6d 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -5,13 +5,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" -import { SessionV2 } from "@opencode-ai/core/session" +import { AgentV2 } from "@opencode-ai/core/agent" import { Shell } from "@opencode-ai/schema/shell" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) +const build = AgentV2.defaultID describe("toLLMMessages", () => { test("omits empty assistant turns", () => { @@ -19,7 +20,7 @@ describe("toLLMMessages", () => { SessionMessage.Assistant.make({ id: id(value), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content, time: { created, completed: created }, @@ -27,17 +28,14 @@ describe("toLLMMessages", () => { const messages = toLLMMessages( [ assistant("empty", []), - assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]), - assistant("empty-reasoning", [ - SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }), - ]), - assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]), + assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]), + assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]), + assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]), assistant("reasoning", [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning", text: "", - providerMetadata: { anthropic: { signature: "sig_1" } }, + state: { signature: "sig_1" }, }), ]), ], @@ -59,7 +57,7 @@ describe("toLLMMessages", () => { SessionMessage.AgentSelected.make({ id: id("agent"), type: "agent-switched", - agent: "build", + agent: build, time: { created }, }), SessionMessage.ModelSelected.make({ @@ -85,30 +83,23 @@ describe("toLLMMessages", () => { SessionMessage.Synthetic.make({ id: id("synthetic"), type: "synthetic", - sessionID: SessionV2.ID.make("ses_translate"), text: "Synthetic context", time: { created }, }), SessionMessage.Shell.make({ id: id("shell"), type: "shell", - shell: Shell.Info.make({ - id: Shell.ID.make("sh_test"), - status: "exited", - command: "pwd", - cwd: "/project", - shell: "/bin/sh", - file: "/tmp/sh_test.out", - exit: 0, - metadata: {}, - time: { started: 0, completed: 0 }, - }), + shellID: Shell.ID.make("sh_test"), + status: "exited", + command: "pwd", + exit: 0, output: { output: "/project", cursor: 8, size: 8, truncated: false }, time: { created, completed: created }, }), SessionMessage.Compaction.make({ id: id("compaction"), type: "compaction", + status: "completed", reason: "auto", summary: "Earlier work", recent: "Recent work", @@ -153,7 +144,7 @@ Recent work ]) }) - test("lowers text attachments as separate user messages", () => { + test("lowers text attachments after the prompt in one user message", () => { const file = FileAttachment.make({ data: Base64.make(Buffer.from("export const value = 1").toString("base64")), mime: "text/plain", @@ -173,21 +164,18 @@ Recent work model, ) - expect(messages).toHaveLength(2) + expect(messages).toHaveLength(1) expect(messages[0]).toMatchObject({ - role: "user", - content: [ - { - type: "text", - text: "Attached file: main.ts\n\nexport const value = 1", - }, - ], - metadata: { attachment: { source: file.source, name: "main.ts" } }, - }) - expect(messages[1]).toMatchObject({ id: id("user-text-file"), role: "user", - content: [{ type: "text", text: "Review this file" }], + content: [ + { type: "text", text: "Review this file" }, + { + type: "text", + text: "\n\nAttached file: main.ts\n\nexport const value = 1", + metadata: { attachment: { source: file.source, name: "main.ts" } }, + }, + ], }) }) @@ -212,14 +200,110 @@ Recent work model, ) - expect(messages[0]?.content).toEqual([ + expect(messages[0]?.content).toMatchObject([ + { type: "text", text: "Review this file" }, { type: "text", - text: "Attached file: inline.txt\n\ninline content", + text: "\n\nAttached file: inline.txt\n\ninline content", }, ]) }) + test("lowers directory attachments as directory context", () => { + const directory = FileAttachment.make({ + data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }) + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-directory"), + type: "user", + text: "Review this directory", + files: [directory], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ + id: id("user-directory"), + role: "user", + content: [ + { type: "text", text: "Review this directory" }, + { + type: "text", + text: "\n\nAttached directory: src/\n\nlib/\nindex.ts", + metadata: { attachment: { source: directory.source, name: "src/" } }, + }, + ], + }) + }) + + test("preserves attachment order after the prompt", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-mixed-files"), + type: "user", + text: "Review these attachments", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }), + FileAttachment.make({ + data: Base64.make(Buffer.from("export const value = 1").toString("base64")), + mime: "text/plain", + source: { type: "uri", uri: "file:///project/main.ts" }, + name: "main.ts", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([ + "Review these attachments", + "\n\nAttached directory: src/\n\nindex.ts", + "\n\nAttached file: main.ts\n\nexport const value = 1", + ]) + }) + + test("omits empty prompt text before an attachment", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-attachment-only"), + type: "user", + text: "", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }]) + }) + test("uses materialized image data as provider media and drops unsupported attachments", () => { const data = Base64.make("AAECAw==") const messages = toLLMMessages( @@ -255,21 +339,20 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }), + SessionMessage.AssistantText.make({ type: "text", text: "Checking" }), SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-1", text: "Think", - providerMetadata: { anthropic: { signature: "sig_1" } }, + state: { signature: "sig_1" }, }), SessionMessage.AssistantTool.make({ type: "tool", id: "pending", name: "read", - state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), + state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }), time: { created }, }), SessionMessage.AssistantTool.make({ @@ -308,11 +391,9 @@ Recent work type: "tool", id: "hosted", name: "web_search", - provider: { - executed: true, - metadata: { fake: { continuation: "hosted-call" } }, - resultMetadata: { fake: { continuation: "hosted-result" } }, - }, + executed: true, + providerState: { continuation: "hosted-call" }, + providerResultState: { continuation: "hosted-result" }, state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, @@ -325,7 +406,8 @@ Recent work type: "tool", id: "hosted-failed", name: "write", - provider: { executed: true, metadata: { fake: { continuation: "failed" } } }, + executed: true, + providerState: { continuation: "failed" }, state: SessionMessage.ToolStateError.make({ status: "error", input: { path: "README.md" }, @@ -345,7 +427,7 @@ Recent work expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"]) expect(messages[0]?.content).toEqual([ { type: "text", text: "Checking" }, - { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } }, + { type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } }, { type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } }, { type: "tool-call", id: "running", name: "read", input: { path: "README.md" } }, { @@ -360,14 +442,14 @@ Recent work name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { continuation: "hosted-call" } }, + providerMetadata: { provider: { continuation: "hosted-call" } }, }, { type: "tool-result", id: "hosted", name: "web_search", providerExecuted: true, - providerMetadata: { fake: { continuation: "hosted-result" } }, + providerMetadata: { provider: { continuation: "hosted-result" } }, result: { type: "text", value: "Found it" }, }, { @@ -376,14 +458,14 @@ Recent work name: "write", input: { path: "README.md" }, providerExecuted: true, - providerMetadata: { fake: { continuation: "failed" } }, + providerMetadata: { provider: { continuation: "failed" } }, }, { type: "tool-result", id: "hosted-failed", name: "write", providerExecuted: true, - providerMetadata: { fake: { continuation: "failed" } }, + providerMetadata: { provider: { continuation: "failed" } }, result: { type: "error", value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} }, @@ -412,14 +494,13 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-openai-reasoning"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-openai", text: "Think", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, }), ], time: { created, completed: created }, @@ -432,35 +513,60 @@ Recent work { type: "reasoning", text: "Think", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) }) - test("drops provider-native continuation metadata from failed assistant turns", () => { + test("replays flat state under an OpenCode hosted model's route key", () => { + const opencode = ModelV2.Ref.make({ id: ModelV2.ID.make("claude-fable-5"), providerID: ProviderV2.ID.opencode }) + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-opencode-reasoning"), + type: "assistant", + agent: build, + model: opencode, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + text: "Think", + state: { signature: "signed" }, + }), + ], + time: { created, completed: created }, + }), + ], + opencode, + "anthropic", + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } }, + ]) + }) + + test("lowers failed assistant reasoning to text", () => { const messages = toLLMMessages( [ SessionMessage.Assistant.make({ id: id("assistant-failed"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-failed", text: "Partial thought", - providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + state: { itemId: "rs_failed", reasoningEncryptedContent: null }, }), SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-failed", name: "web_search", - provider: { - executed: true, - metadata: { openai: { itemId: "call_failed" } }, - resultMetadata: { openai: { itemId: "result_failed" } }, - }, + executed: true, + providerState: { itemId: "call_failed" }, + providerResultState: { itemId: "result_failed" }, state: SessionMessage.ToolStateError.make({ status: "error", input: { query: "Effect" }, @@ -480,7 +586,7 @@ Recent work ) expect(messages[0]?.content).toEqual([ - { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { type: "text", text: "Partial thought" }, { type: "tool-call", id: "hosted-failed", @@ -515,24 +621,21 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-old-model"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-old-model", text: "Visible thought", - providerMetadata: { anthropic: { signature: "sig_old" } }, + state: { signature: "sig_old" }, }), SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-old-model", name: "web_search", - provider: { - executed: true, - metadata: { openai: { itemId: "hosted-old-model" } }, - resultMetadata: { openai: { itemId: "hosted-old-model" } }, - }, + executed: true, + providerState: { itemId: "hosted-old-model" }, + providerResultState: { itemId: "hosted-old-model" }, state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, @@ -546,11 +649,9 @@ Recent work type: "tool", id: "local-old-model", name: "read", - provider: { - executed: false, - metadata: { fake: { call: "old" } }, - resultMetadata: { fake: { result: "old" } }, - }, + executed: false, + providerState: { call: "old" }, + providerResultState: { result: "old" }, state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, @@ -615,14 +716,13 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-alias"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-alias", text: "Visible thought", - providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, + state: { reasoningEncryptedContent: "encrypted" }, }), ], time: { created, completed: created }, @@ -635,7 +735,7 @@ Recent work { type: "reasoning", text: "Visible thought", - providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, + providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } }, }, ]) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 14bfc37bbe..f3c666b4a9 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { LLM, Model } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" +import { Money } from "@opencode-ai/schema/money" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" @@ -52,6 +53,7 @@ describe("SessionRunnerModel", () => { expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) expect(resolved.route).toMatchObject({ id: "openai-responses", + providerMetadataKey: "openai", endpoint: { baseURL: "https://openai.example/v1" }, defaults: { headers: { "x-test": "header" }, @@ -131,7 +133,7 @@ describe("SessionRunnerModel", () => { providerID: catalog.providerID, variant: ModelV2.VariantID.make("high"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -170,7 +172,7 @@ describe("SessionRunnerModel", () => { projectID: ProjectV2.ID.global, title: "test", model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -200,7 +202,7 @@ describe("SessionRunnerModel", () => { providerID: catalog.providerID, variant: ModelV2.VariantID.make("unknown"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -236,7 +238,7 @@ describe("SessionRunnerModel", () => { projectID: ProjectV2.ID.global, title: "test", model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -263,6 +265,7 @@ describe("SessionRunnerModel", () => { expect(resolved.route).toMatchObject({ id: "anthropic-messages", + providerMetadataKey: "anthropic", endpoint: { baseURL: "https://anthropic.example/v1" }, }) }), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index aa2484d34f..36b8f85061 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,5 +1,4 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Database } from "@opencode-ai/core/database/database" @@ -37,21 +36,20 @@ import { Instructions } from "@opencode-ai/core/instructions" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const cassette = - process.env.RECORD === "true" - ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - mode: "record", - }) - : HttpRecorder.http("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - }) +const cassetteName = "session-runner/openai-chat-streams-text" +const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") +if (process.env.RECORD === "true") { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(cassetteName, { directory: cassetteDirectory }) +} +const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) const client = LLMClient.layer.pipe(Layer.provide(executor)) const permission = Layer.succeed( @@ -79,6 +77,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -92,6 +91,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Config.node, config], [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ]) const execution = Layer.effect( SessionExecution.Service, @@ -140,6 +140,7 @@ const it = testEffect( [ReferenceGuidance.node, referenceGuidance], [Config.node, config], [Snapshot.node, Snapshot.noopLayer], + [PluginSupervisor.node, pluginSupervisor], [SessionExecution.node, execution], ], ), @@ -149,6 +150,12 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded") describe("SessionRunnerLLM recorded", () => { it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () => Effect.gen(function* () { + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) const { db } = yield* Database.Service yield* db .insert(ProjectTable) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index de4700ca07..179818d852 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -1,7 +1,9 @@ import { expect, test } from "bun:test" import { Effect, Schema, Stream } from "effect" import { LLMEvent } from "@opencode-ai/llm" +import { Money } from "@opencode-ai/schema/money" import { EventV2 } from "@opencode-ai/core/event" +import { AgentV2 } from "@opencode-ai/core/agent" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" @@ -12,7 +14,7 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis const sessionID = SessionV2.ID.make("ses_tool_event_test") const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" -const capture = () => { +const capture = (providerMetadataKey = "anthropic") => { const published: Array<{ readonly type: string; readonly data: unknown }> = [] const events = EventV2.Service.of({ publish: (definition, data) => @@ -40,11 +42,12 @@ const capture = () => { published, publisher: createLLMEventPublisher(events, { sessionID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), - providerID: ProviderV2.ID.make("provider"), + providerID: ProviderV2.ID.opencode, }, + providerMetadataKey, }), } } @@ -88,7 +91,7 @@ test("local tool success serializes media base64 once and reconstructs from stru }) }) -test("provider-executed success retains its compatibility result", async () => { +test("provider-executed success retains its raw provider result", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) @@ -96,6 +99,79 @@ test("provider-executed success retains its compatibility result", async () => { expect(success?.data).toHaveProperty("result") }) +test("provider metadata is flattened using the route key", async () => { + const { published, publisher } = capture() + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }), + ), + ) + + expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({ + state: { signature: "signed" }, + }) +}) + +test("reasoning state from start, empty delta, and end is merged", async () => { + const { published, publisher } = capture() + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningDelta({ + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } }, + }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }), + ), + ) + + expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({ + state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" }, + }) +}) + +test("provider-executed tool metadata is flattened using the route key", async () => { + const { published, publisher } = capture("openai") + await Effect.runPromise( + publisher.publish( + LLMEvent.toolCall({ + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "call" } }, + }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.toolResult({ + id: "hosted", + name: "web_search", + result: { type: "json", value: { found: true } }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "result" } }, + }), + ), + ) + + expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({ + state: { itemId: "call" }, + }) + expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({ + resultState: { itemId: "result" }, + }) +}) + test("binary failure emits no success event", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call)) @@ -112,7 +188,7 @@ test("binary failure emits no success event", async () => { expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true) }) -test("old success event data containing result still decodes", () => { +test("success event data can carry a provider-executed result", () => { const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ sessionID, assistantMessageID: SessionMessage.ID.create(), @@ -120,7 +196,7 @@ test("old success event data containing result still decodes", () => { structured: { type: "media", mime: "image/png" }, content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] }, - provider: { executed: false }, + executed: true, }) expect(decoded.result).toMatchObject({ type: "content" }) }) @@ -133,3 +209,64 @@ test("step finish records settlement without publishing step ended", async () => expect(published.some((event) => event.type === "step.ended.2")).toBe(false) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) }) + +test("content-filter finish retains failure evidence until step closeout", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.stepFinish({ + index: 0, + reason: "content-filter", + usage: { + nonCachedInputTokens: 8, + outputTokens: 3, + reasoningTokens: 1, + }, + }), + ), + ) + + expect(published.map((event) => event.type)).toEqual(["session.step.started.1"]) + const settlement = publisher.stepSettlement() + expect(settlement).toMatchObject({ + finish: "content-filter", + tokens: { input: 8, output: 2, reasoning: 1 }, + }) + if (!settlement) throw new Error("Expected content-filter settlement") + await Effect.runPromise( + publisher.publishStepFailure({ + cost: Money.USD.make(1.25), + tokens: settlement.tokens, + }), + ) + expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"]) + expect(published.at(-1)?.data).toMatchObject({ + error: { type: "provider.content-filter", message: "Provider blocked the response" }, + cost: 1.25, + tokens: { input: 8, output: 2, reasoning: 1 }, + }) +}) + +test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => { + const { published, publisher } = capture() + await Effect.runPromise( + Effect.forEach( + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text" }), + LLMEvent.textDelta({ id: "text", text: "Partial" }), + LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + ], + (event) => publisher.publish(event), + { discard: true }, + ), + ) + await Effect.runPromise(publisher.publishStepFailure()) + + expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false) + expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" }) + expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({ + error: { type: "provider.content-filter" }, + }) +}) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e..2944f07972 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -82,24 +82,27 @@ describe("ToolRegistry", () => { }), ) - it.effect("selects one edit tool family for each model", () => + it.effect("materializes all permission-eligible edit tools before request policy", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ read: make(), edit: make("edit"), write: make("edit"), - apply_patch: make("edit"), + patch: make("edit"), }) const names = (model: ToolRegistry.MaterializeInput["model"]) => service .materialize({ model }) .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) - expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) + expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"]) + expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([ + "read", + "edit", + "write", + "patch", + ]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 83d7e04abf..35091f37b9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,11 +1,13 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { LLMClient, LLMError, LLMEvent, Model, + ToolFailure, TransportReason, InvalidRequestReason, + RateLimitReason, type LLMClientShape, type LLMRequest, } from "@opencode-ai/llm" @@ -16,7 +18,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" -import { Job } from "@opencode-ai/core/job" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -26,11 +27,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionCompaction } from "@opencode-ai/core/session/compaction" -import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Money } from "@opencode-ai/schema/money" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" @@ -63,6 +64,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { TestClock } from "effect/testing" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -103,6 +105,20 @@ const client = Layer.succeed( generate: () => Effect.die("unused"), }), ) +const reply = { + stop: () => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents, + tool: (id: string, name: string, input: unknown) => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id, name, input }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], +} const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) const defaultSystem = SessionRunnerSystemPrompt.provider(model) const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) @@ -116,6 +132,53 @@ const recoveryModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), }) + +test("calculates step cost using the matching context tier", () => { + expect( + SessionRunnerLLM.calculateCost( + [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.make(0.5), + }, + }, + { + tier: { type: "context", size: 100 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { + read: Money.USDPerMillionTokens.make(0.2), + write: Money.USDPerMillionTokens.make(0.6), + }, + }, + ], + { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } }, + ), + ).toBeCloseTo(0.0002926) +}) + +test("does not apply an ineligible tier without base pricing", () => { + expect( + SessionRunnerLLM.calculateCost( + [ + { + tier: { type: "context", size: 100 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { + read: Money.USDPerMillionTokens.make(0.2), + write: Money.USDPerMillionTokens.make(0.6), + }, + }, + ], + { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } }, + ), + ).toBe(Money.USD.zero) +}) + const authorizations: Tool.Context[] = [] const executions: string[] = [] const permission = Layer.succeed( @@ -154,7 +217,10 @@ const echo = Layer.effectDiscard( description: "Fail unexpectedly", input: Schema.Struct({}), output: Schema.Struct({}), - execute: () => Effect.die("unexpected tool defect"), + execute: () => + (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( + Effect.andThen(Effect.die("unexpected tool defect")), + ), }), // BigInt output with no model content forces ToolOutputStore.bound onto its // JSON.stringify encode path, which fails with a typed StorageError. @@ -242,6 +308,13 @@ const config = Layer.succeed( ]), }), ) +let pluginFlushHook = Effect.void +const pluginSupervisor = Layer.succeed( + PluginSupervisor.Service, + PluginSupervisor.Service.of({ + flush: Effect.suspend(() => pluginFlushHook), + }), +) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -255,6 +328,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Config.node, config], [McpGuidance.node, mcpGuidance], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ]) const execution = Layer.effect( SessionExecution.Service, @@ -309,11 +383,14 @@ const it = testEffect( [SessionExecution.node, execution], [Config.node, config], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ], ), ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") +const admit = (session: SessionV2.Interface, text: string) => + session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text }), resume: false }) const insertSession = (id: SessionV2.ID) => Effect.gen(function* () { @@ -335,12 +412,16 @@ const insertSession = (id: SessionV2.ID) => const setup = Effect.gen(function* () { const { db } = yield* Database.Service + requests.length = 0 + authorizations.length = 0 + executions.length = 0 response = [] systemBaseline = "Initial context" systemRemoved = false systemUnavailable = false systemLoadHook = Effect.void modelResolveHook = Effect.void + pluginFlushHook = Effect.void currentModel = model skillBaselines.clear() responses = undefined @@ -353,6 +434,12 @@ const setup = Effect.gen(function* () { toolExecutionsReady = 5 activeToolExecutions = 0 maxActiveToolExecutions = 0 + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) @@ -360,6 +447,7 @@ const setup = Effect.gen(function* () { .run() .pipe(Effect.orDie) yield* insertSession(sessionID) + return yield* SessionV2.Service }) const providerUnavailable = () => @@ -369,15 +457,24 @@ const providerUnavailable = () => reason: new TransportReason({ message: "Provider unavailable" }), }) -const setupOverflowRecovery = Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(700) }), - resume: false, +const invalidRequest = () => + new LLMError({ + module: "test", + method: "stream", + reason: new InvalidRequestReason({ message: "Invalid request" }), }) + +const rateLimited = (retryAfterMs?: number) => + new LLMError({ + module: "test", + method: "stream", + reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }), + }) + +const setupOverflowRecovery = Effect.gen(function* () { + const session = yield* setup + response = reply.text("Earlier answer", "text-earlier") + yield* admit(session, "Earlier question ".repeat(700)) yield* session.resume(sessionID) currentModel = recoveryModel requests.length = 0 @@ -406,6 +503,37 @@ const recordedEventTypes = (id: SessionV2.ID) => ) }) +const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: SessionMessage.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + const settlementTypes = new Set([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.tool.failed.1", + "session.step.ended.1", + "session.step.failed.1", + ]) + return (yield* db + .select({ type: EventTable.type, data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie)).filter( + (event) => settlementTypes.has(event.type) && event.data.assistantMessageID === assistantMessageID, + ) + }) + +const hostedCall = (id: string, query: string) => + LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true }) + +const requireAssistant = (messages: readonly SessionMessage.Info[]) => { + const assistant = messages.find((message) => message.type === "assistant") + if (!assistant) throw new Error("Assistant message missing") + return assistant +} + const replaySessionProjection = (id: SessionV2.ID) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -456,7 +584,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.textStart({ id }), ...chunks.map((text) => LLMEvent.textDelta({ id, text })), ] - const expectedContent = { type: "text", id, text } + const expectedContent = { type: "text", text } return { delta: SessionEvent.Text.Delta, partialEvents, @@ -476,7 +604,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.reasoningStart({ id }), ...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })), ] - const expectedContent = { type: "reasoning", id, text } + const expectedContent = { type: "reasoning", text } return { delta: SessionEvent.Reasoning.Delta, partialEvents, @@ -496,7 +624,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.toolInputStart({ id, name: "echo" }), ...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })), ] - const expectedContent = { type: "tool", id, state: { status: "pending", input: text } } + const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } } return { delta: SessionEvent.Tool.Input.Delta, partialEvents, @@ -510,13 +638,12 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string const verifyEphemeralDeltas = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Stream ${kind}` const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -542,12 +669,11 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const verifyPartialFlushOnFailure = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -556,20 +682,20 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.transport", message: "Provider unavailable" }, content: [fixture.expectedContent], }, ]) + expect(requests).toHaveLength(1) }) const verifyPartialFlushOnInterruption = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -584,7 +710,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "assistant", finish: "error", - error: { type: "unknown", message: "Step interrupted" }, + error: { type: "aborted", message: "Step interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -597,9 +723,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => describe("SessionRunnerLLM", () => { it.effect("advertises and executes a location registered tool", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const registry = yield* ToolRegistry.Service - const session = yield* SessionV2.Service const contexts: Tool.Context[] = [] yield* registry.register({ location_context: Tool.make({ @@ -613,16 +738,8 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use application context" }), resume: false }) - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + yield* admit(session, "Use application context") + responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] yield* session.resume(sessionID) @@ -653,15 +770,12 @@ describe("SessionRunnerLLM", () => { it.effect("starts a real runner turn after default prompt recording", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined - response = [] + const session = yield* setup - const message = yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run automatically" }) }) + const message = yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Run automatically" }), + }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -673,16 +787,10 @@ describe("SessionRunnerLLM", () => { it.effect("streams one request with registry definitions from chronological V2 user history", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") + yield* admit(session, "Second") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined - response = [] yield* session.resume(sessionID) expect(requests).toHaveLength(1) @@ -698,13 +806,16 @@ describe("SessionRunnerLLM", () => { it.effect("retries the first provider turn after system context becomes available", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - requests.length = 0 + yield* session.prompt({ + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "First" }), + resume: false, + }) const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -731,13 +842,10 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts a source Location runner after a Session moves", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - requests.length = 0 - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) yield* events.publish(SessionEvent.Moved, { @@ -752,7 +860,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -763,11 +871,9 @@ describe("SessionRunnerLLM", () => { it.effect("copies the context checkpoint to a fork", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) const forked = yield* session.fork({ sessionID }) @@ -792,11 +898,9 @@ describe("SessionRunnerLLM", () => { it.effect("heals an undecodable stored applied record by re-announcing context", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) yield* db .update(InstructionCheckpointTable) @@ -804,7 +908,7 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") requests.length = 0 yield* session.resume(sessionID) @@ -826,15 +930,12 @@ describe("SessionRunnerLLM", () => { it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -860,13 +961,11 @@ describe("SessionRunnerLLM", () => { it.effect("uses the selected model family prompt when the agent does not override it", () => Effect.gen(function* () { - yield* setup + const session = yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents + response = reply.text("Done", "text-provider-prompt") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -878,7 +977,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses the selected model family prompt when the agent system override is empty", () => Effect.gen(function* () { - yield* setup + const session = yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) const agent = yield* AgentV2.Service yield* agent.transform((editor) => @@ -887,11 +986,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents + response = reply.text("Done", "text-empty-agent-system") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -903,7 +1000,7 @@ describe("SessionRunnerLLM", () => { it.effect("includes the effective default agent system before durable context", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { @@ -911,11 +1008,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-build", ["Done"]).completeEvents + response = reply.text("Done", "text-build") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -924,7 +1019,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses the configured default agent system for omitted-agent sessions", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { @@ -937,11 +1032,9 @@ describe("SessionRunnerLLM", () => { }) editor.default(AgentV2.ID.make("reviewer")) }) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents + response = reply.text("Done", "text-reviewer") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -951,7 +1044,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses only the agent prompt and durable baseline as system parts", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { @@ -959,11 +1052,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents + response = reply.text("Done", "text-no-system") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -972,7 +1063,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses an explicitly selected non-build agent system", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service yield* agent.transform((editor) => @@ -987,11 +1078,9 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionTable.id, sessionID)) .run() .pipe(Effect.orDie) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents + response = reply.text("Done", "text-selected") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -999,23 +1088,74 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("updates selected-agent skill guidance after an agent switch", () => + it.effect("fails before the model request when the selected agent is unavailable", () => Effect.gen(function* () { yield* setup + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ agent: "explore" }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false }) requests.length = 0 response = [] + const failure = yield* session.resume(sessionID).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "Session.AgentNotFoundError", + sessionID, + agent: "explore", + }) + expect(requests).toHaveLength(0) + }), + ) + + it.effect("waits for initial plugin readiness before constructing the model request", () => + Effect.gen(function* () { + yield* setup + const release = yield* Deferred.make() + pluginFlushHook = Deferred.await(release) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false }) + + requests.length = 0 + response = [] + const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) + yield* Effect.yieldNow + + expect(requests).toHaveLength(0) + expect(running.pollUnsafe()).toBeUndefined() + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(running) + expect(requests).toHaveLength(1) + }), + ) + + it.effect("updates selected-agent skill guidance after an agent switch", () => + Effect.gen(function* () { + const session = yield* setup + const events = yield* EventV2.Service + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.mode = "primary" + }), + ) + skillBaselines.set(AgentV2.ID.make("build"), "Build skills") + yield* admit(session, "First") + yield* session.resume(sessionID) skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") yield* events.publish(SessionEvent.AgentSelected, { sessionID, - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1028,8 +1168,7 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") @@ -1040,14 +1179,12 @@ describe("SessionRunnerLLM", () => { return events .publish(SessionEvent.AgentSelected, { sessionID, - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1058,8 +1195,7 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the sampled model when selection changes during model resolution", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service let switched = false modelResolveHook = Effect.suspend(() => { @@ -1072,10 +1208,8 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1086,15 +1220,12 @@ describe("SessionRunnerLLM", () => { it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1107,14 +1238,11 @@ describe("SessionRunnerLLM", () => { it.effect("renders API context entries through the belief lifecycle", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const contextEntries = yield* InstructionEntry.Service yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) // String values render verbatim inside the tagged block at baseline. @@ -1125,7 +1253,7 @@ describe("SessionRunnerLLM", () => { // Non-string JSON pretty-prints; the change narrates as a System update. yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1146,7 +1274,7 @@ describe("SessionRunnerLLM", () => { // Deleting the row announces removal through the stored removal text. yield* contextEntries.remove({ sessionID, key: "deploy-target" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) @@ -1159,23 +1287,20 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1195,31 +1320,28 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fourth" }), resume: false }) + yield* admit(session, "Fourth") yield* session.resume(sessionID) }), ) it.effect("preserves the baseline while context is temporarily unavailable", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1232,18 +1354,15 @@ describe("SessionRunnerLLM", () => { it.effect("rebuilds the baseline directly after completed compaction", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) - const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -1252,7 +1371,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1260,34 +1379,160 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) }), ) + it.effect("runs one durable compaction barrier before later steer and queued prompts", () => + Effect.gen(function* () { + const session = yield* setup + currentModel = recoveryModel + streamGate = yield* Deferred.make() + streamStarted = yield* Deferred.make() + responses = [ + reply.text("Active complete", "text-active"), + [LLMEvent.textDelta({ id: "summary", text: "durable summary" })], + reply.text("Steer complete", "text-steer"), + reply.text("Queue complete", "text-queue"), + ] + yield* admit(session, "Active work") + const active = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(streamStarted) + + const first = yield* session.compact({ sessionID }) + const second = yield* session.compact({ sessionID }) + expect(second.id).toBe(first.id) + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({ + id: first.id, + }) + expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined() + + yield* admit(session, "Steer after compaction") + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }), + delivery: "queue", + resume: false, + }) + expect(yield* SessionInput.hasPending((yield* Database.Service).db, sessionID, "steer")).toBe(false) + + yield* Deferred.succeed(streamGate, undefined) + yield* Fiber.join(active) + + expect(requests).toHaveLength(4) + expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary") + expect(userTexts(requests[2])).toContain("Steer after compaction") + expect(userTexts(requests[3])).toContain("Queue after compaction") + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({ + type: "compaction", + status: "completed", + summary: "durable summary", + }) + }), + ) + + it.effect("releases queued prompts when durable compaction fails", () => + Effect.gen(function* () { + const session = yield* setup + currentModel = recoveryModel + streamGate = yield* Deferred.make() + streamStarted = yield* Deferred.make() + responses = [ + reply.text("Active complete", "text-active-failure"), + [], + reply.text("Continued", "text-after-failure"), + ] + yield* admit(session, "Active work") + const active = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(streamStarted) + + const compaction = yield* session.compact({ sessionID }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Continue after failure" }), + delivery: "queue", + resume: false, + }) + yield* Deferred.succeed(streamGate, undefined) + yield* Fiber.join(active) + + expect(requests).toHaveLength(3) + expect(userTexts(requests[2])).toContain("Continue after failure") + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + + it.effect("settles an admitted manual compaction that cannot start", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const compaction = yield* session.compact({ sessionID }) + + yield* session.resume(sessionID) + + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + reason: "manual", + error: { message: "Compaction could not start" }, + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + + it.effect("settles an admitted manual compaction when pre-start resolution throws", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const compaction = yield* session.compact({ sessionID }) + modelResolveHook = Effect.die("model resolution failed") + + expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" }) + + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + reason: "manual", + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + it.effect("automatically compacts into a completed summary and retained recent turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(180) }), - resume: false, - }) + const session = yield* setup + response = reply.text("Earlier answer", "text-first") + yield* admit(session, "Earlier question ".repeat(180)) yield* session.resume(sessionID) currentModel = compactModel requests.length = 0 responses = [ - fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents, - fragmentFixture("text", "text-final", ["Continued"]).completeEvents, + reply.text("## Objective\n- Preserve the task", "text-summary"), + reply.text("Continued", "text-final"), ] - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recent exact request ".repeat(180) }), - resume: false, - }) + yield* admit(session, "Recent exact request ".repeat(180)) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1306,14 +1551,10 @@ describe("SessionRunnerLLM", () => { requests.length = 0 executions.length = 0 responses = [ - fragmentFixture("text", "text-summary-2", ["## Objective\n- Preserve the updated task"]).completeEvents, - fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, + reply.text("## Objective\n- Preserve the updated task", "text-summary-2"), + reply.text("Continued again", "text-final-2"), ] - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Newest exact request ".repeat(180) }), - resume: false, - }) + yield* admit(session, "Newest exact request ".repeat(180)) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1336,10 +1577,10 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ], - fragmentFixture("text", "text-summary", ["## Objective\n- Recover overflow"]).completeEvents, - fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + reply.text("## Objective\n- Recover overflow", "text-summary"), + reply.text("Recovered", "text-final"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1364,13 +1605,9 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ] - responses = [ - overflow(), - fragmentFixture("text", "text-summary", ["## Objective\n- Recover once"]).completeEvents, - overflow(), - ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) - yield* session.resume(sessionID) + responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()] + yield* admit(session, "Continue") + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(3) expect(yield* session.context(sessionID)).toMatchObject([ @@ -1394,10 +1631,10 @@ describe("SessionRunnerLLM", () => { }), ) responses = [ - fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents, - fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + reply.text("## Objective\n- Recover raw overflow", "text-summary"), + reply.text("Recovered", "text-final"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1415,14 +1652,15 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) - yield* session.resume(sessionID) + yield* admit(session, "Continue") + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(2) const context = yield* session.context(sessionID) - expect(context.some((message) => message.type === "compaction")).toBe(false) - expect(context.slice(-2)).toMatchObject([ + expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" })) + expect(context.slice(-3)).toMatchObject([ { type: "user", text: "Continue" }, + { type: "compaction", status: "failed", reason: "auto" }, { type: "assistant", finish: "error", error: { message: "prompt too long" } }, ]) }), @@ -1433,12 +1671,12 @@ describe("SessionRunnerLLM", () => { const session = yield* setupOverflowRecovery responses = [ [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - fragmentFixture("text", "text-summary", ["## Objective\n- Interrupted"]).completeEvents, + reply.text("## Objective\n- Interrupted", "text-summary"), ] const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1449,27 +1687,26 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) streamGate = undefined expect(requests).toHaveLength(2) - expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false) + expect(yield* session.context(sessionID)).toContainEqual( + expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }), + ) }), ) it.effect("rebaselines after compaction from the last-applied belief while unobservable", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) - const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -1478,7 +1715,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) // The rebaseline proceeds while the source is unobservable, restating the model's belief. @@ -1489,14 +1726,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects reasoning and tool events without executing or continuing tools", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use tools" }), resume: false }) + const session = yield* setup + yield* admit(session, "Use tools") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-1" }), @@ -1513,7 +1745,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "hello" }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.toolResult({ id: "call-provider", @@ -1526,7 +1758,7 @@ describe("SessionRunnerLLM", () => { ], }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.stepFinish({ index: 0, @@ -1551,9 +1783,10 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "tool-calls", + cost: 0, tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } }, content: [ - { type: "reasoning", id: "reasoning-1", text: "Think" }, + { type: "reasoning", text: "Think" }, { type: "tool", id: "call-error", @@ -1561,14 +1794,16 @@ describe("SessionRunnerLLM", () => { state: { status: "error", input: { path: "README.md" }, - error: { type: "unknown", message: "Denied" }, + error: { type: "tool.execution", message: "Denied" }, }, }, { type: "tool", id: "call-provider", name: "web_search", - provider: { executed: true, metadata: { fake: { source: "provider" } } }, + executed: true, + providerState: { source: "provider" }, + providerResultState: { source: "provider" }, state: { status: "completed", input: { query: "hello" }, @@ -1587,31 +1822,10 @@ describe("SessionRunnerLLM", () => { it.effect("continues with reloaded history after durably settling one local tool call", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo this") - requests.length = 0 - authorizations.length = 0 - executions.length = 0 - streamGate = undefined - streamStarted = undefined - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-final" }), - LLMEvent.textDelta({ id: "text-final", text: "Done" }), - LLMEvent.textEnd({ id: "text-final" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")] yield* session.resume(sessionID) @@ -1619,7 +1833,8 @@ describe("SessionRunnerLLM", () => { expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }]) expect(executions).toEqual(["hello"]) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Echo this" }, { type: "assistant", @@ -1638,32 +1853,25 @@ describe("SessionRunnerLLM", () => { }, ], }, - { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] }, + ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.ended.1", ]) }), ) it.effect("reloads a model switch before a tool-driven continuation turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) + yield* admit(session, "Echo this") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()] toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -1688,24 +1896,31 @@ describe("SessionRunnerLLM", () => { it.effect("restores durable reasoning provider metadata in a second-turn request", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Think first" }), resume: false }) + const session = yield* setup + yield* admit(session, "Think first") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-anthropic" }), LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }), - LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }), + LLMEvent.reasoningEnd({ + id: "reasoning-anthropic", + providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } }, + }), LLMEvent.reasoningStart({ id: "reasoning-openai", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + providerMetadata: { + openai: { itemId: "rs_1", reasoningEncryptedContent: null }, + anthropic: { ignored: true }, + }, }), LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), LLMEvent.reasoningEnd({ id: "reasoning-openai", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { + openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + anthropic: { ignored: true }, + }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -1718,22 +1933,30 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } }, + { + type: "reasoning", + text: "Signed thought", + state: { signature: "sig_1" }, + }, { type: "reasoning", text: "Encrypted thought", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, }, ], }, ]) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") response = [] yield* session.resume(sessionID) expect(requests[1]?.messages[1]?.content).toEqual([ - { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } }, + { + type: "reasoning", + text: "Signed thought", + providerMetadata: { openai: { signature: "sig_1" } }, + }, { type: "reasoning", text: "Encrypted thought", @@ -1745,11 +1968,9 @@ describe("SessionRunnerLLM", () => { it.effect("replays durable provider-executed tool results inline in a second-turn request", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Search first" }), resume: false }) + const session = yield* setup + yield* admit(session, "Search first") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -1757,14 +1978,14 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { openai: { itemId: "hosted-search" } }, + providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } }, }), LLMEvent.toolResult({ id: "hosted-search", name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -1772,7 +1993,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") response = [] yield* session.resume(sessionID) @@ -1792,7 +2013,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + providerMetadata: { openai: { blockType: "web_search_tool_result" } }, }, ]) }), @@ -1800,17 +2021,12 @@ describe("SessionRunnerLLM", () => { it.effect("starts recorded local tools eagerly and awaits settlement before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo five times" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo five times") - requests.length = 0 - executions.length = 0 toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() const providerGate = yield* Deferred.make() - response = [] - responses = undefined const initial = Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), ...Array.from({ length: 5 }, (_, index) => @@ -1821,7 +2037,6 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]) - streamGate = undefined responseStream = Stream.concat( initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)), @@ -1861,25 +2076,12 @@ describe("SessionRunnerLLM", () => { it.effect("settles repeated provider-local tool call IDs against their owning assistant messages", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo twice" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo twice") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "first" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "second" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("tool_0", "echo", { text: "first" }), + reply.tool("tool_0", "echo", { text: "second" }), [], ] @@ -1949,20 +2151,10 @@ describe("SessionRunnerLLM", () => { it.effect("joins concurrent resume calls into one active provider run", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run once" }), resume: false }) + const session = yield* setup + yield* admit(session, "Run once") - requests.length = 0 - responses = undefined - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-once" }), - LLMEvent.textDelta({ id: "text-once", text: "Once" }), - LLMEvent.textEnd({ id: "text-once" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ] + response = reply.text("Once", "text-once") streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -1981,30 +2173,17 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Run once" }, - { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Once" }] }, ]) }), ) it.effect("steers an active provider turn with newly recorded prompts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2031,29 +2210,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2078,20 +2238,11 @@ describe("SessionRunnerLLM", () => { it.effect("preserves durable queued input for a later wake after interruption", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* admit(session, "Interrupt current work") - requests.length = 0 - responses = [ - [], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [[], reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2121,20 +2272,11 @@ describe("SessionRunnerLLM", () => { it.effect("preserves durable steering input for a later resume after interruption", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* admit(session, "Interrupt current work") - requests.length = 0 - responses = [ - [], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [[], reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2164,28 +2306,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2207,9 +2331,8 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start steering" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start steering") yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue for later" }), @@ -2217,19 +2340,7 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] yield* session.resume(sessionID) @@ -2241,33 +2352,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()] const firstGate = yield* Deferred.make() const secondGate = yield* Deferred.make() streamGate = firstGate @@ -2280,7 +2368,10 @@ describe("SessionRunnerLLM", () => { yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }) }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }), + }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2306,23 +2397,10 @@ describe("SessionRunnerLLM", () => { it.effect("coalesces multiple active steering prompts into one continuation turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2346,14 +2424,10 @@ describe("SessionRunnerLLM", () => { it.effect("runs steering input accepted while the active provider turn fails", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = undefined - response = [] - streamFailure = providerUnavailable() + streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2375,16 +2449,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails local tools left running by a prior process before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), resume: false }) + yield* admit(session, "Recover interrupted tool") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2403,9 +2476,8 @@ describe("SessionRunnerLLM", () => { sessionID, assistantMessageID, callID: "call-interrupted", - tool: "echo", input: { text: "stale" }, - provider: { executed: false }, + executed: false, }) requests.length = 0 response = [] @@ -2421,7 +2493,10 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-interrupted", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { + status: "error", + error: { type: "tool.stale", message: "Tool execution interrupted: echo" }, + }, }, ], }, @@ -2431,20 +2506,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails hosted tools left running by a prior process before continuing inline", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted hosted tool" }), - resume: false, - }) + yield* admit(session, "Recover interrupted hosted tool") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2463,9 +2533,9 @@ describe("SessionRunnerLLM", () => { sessionID, assistantMessageID, callID: "call-hosted-interrupted", - tool: "web_search", input: { query: "stale" }, - provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } }, + executed: true, + state: { itemId: "call-hosted-interrupted" }, }) requests.length = 0 response = [] @@ -2487,20 +2557,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails pending tool input left by a prior process before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool input" }), - resume: false, - }) + yield* admit(session, "Recover interrupted tool input") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2524,8 +2589,7 @@ describe("SessionRunnerLLM", () => { it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), @@ -2533,7 +2597,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow @@ -2544,22 +2607,17 @@ describe("SessionRunnerLLM", () => { it.effect("retries inbox input after prompt projection rolls back", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), resume: false }) + yield* admit(session, "Recover promoted input") expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false requests.length = 0 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ] + response = reply.stop() yield* (yield* SessionExecution.Service).wake(sessionID) while (requests.length === 0) yield* Effect.yieldNow @@ -2570,21 +2628,15 @@ describe("SessionRunnerLLM", () => { it.effect("does not strand a committed promotion when a post-commit listener defects", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service yield* events.listen((event) => event.type === SessionEvent.PromptPromoted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Run committed promotion" }), - resume: false, - }) + yield* admit(session, "Run committed promotion") - requests.length = 0 yield* session.resume(sessionID) expect(requests).toHaveLength(1) @@ -2594,15 +2646,15 @@ describe("SessionRunnerLLM", () => { it.effect("runs different sessions concurrently", () => Effect.gen(function* () { - yield* setup + const session = yield* setup yield* insertSession(otherSessionID) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false }) + yield* admit(session, "Run first") + yield* session.prompt({ + sessionID: otherSessionID, + prompt: PromptInput.Prompt.make({ text: "Run second" }), + resume: false, + }) - requests.length = 0 - responses = undefined - response = [] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2627,12 +2679,11 @@ describe("SessionRunnerLLM", () => { it.effect("bounds 64-character session prompt cache keys", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const longSessionID = SessionV2.ID.make(`ses_${"a".repeat(64)}`) const otherLongSessionID = SessionV2.ID.make(`ses_${"b".repeat(64)}`) yield* insertSession(longSessionID) yield* insertSession(otherLongSessionID) - const session = yield* SessionV2.Service yield* session.prompt({ sessionID: longSessionID, prompt: PromptInput.Prompt.make({ text: "Run long session" }), @@ -2644,7 +2695,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* session.resume(longSessionID) yield* session.resume(otherLongSessionID) @@ -2657,14 +2707,10 @@ describe("SessionRunnerLLM", () => { it.effect("fans out one failed run and allows a later retry", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), resume: false }) + const session = yield* setup + yield* admit(session, "Retry after failure") - requests.length = 0 - responses = undefined - response = [] - streamFailure = providerUnavailable() + streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2688,30 +2734,10 @@ describe("SessionRunnerLLM", () => { it.effect("durably settles local tool failures before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call missing" }), resume: false }) - - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-missing", name: "missing", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-after-error" }), - LLMEvent.textDelta({ id: "text-after-error", text: "Recovered" }), - LLMEvent.textEnd({ id: "text-after-error" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - streamGate = undefined - streamStarted = undefined + const session = yield* setup + yield* admit(session, "Call missing") + responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")] yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -2723,54 +2749,9 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-missing", - state: { status: "error", error: { message: "Unknown tool: missing" } }, - }, - ], - }, - { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] }, - ]) - }), - ) - - it.effect("returns unexpected local tool defects to the model and continues", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call defect" }), resume: false }) - - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-after-defect" }), - LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }), - LLMEvent.textEnd({ id: "text-after-defect" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(2) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Call defect" }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "call-defect", state: { status: "error", - error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" }, + error: { type: "tool.unknown", message: "Unknown tool: missing" }, }, }, ], @@ -2780,10 +2761,48 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("returns unexpected local tool defects to the model and continues", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Call defect") + + responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ + { type: "user", text: "Call defect" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-defect", + state: { + status: "error", + error: { type: "unknown", message: "unexpected tool defect" }, + }, + }, + ], + }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, + ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.ended.1", + ]) + }), + ) + it.effect("returns policy-blocked tools to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ blocked: Tool.make({ @@ -2791,27 +2810,14 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({}), output: Schema.Struct({}), execute: () => - Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe( + Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), ), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call blocked" }), resume: false }) + yield* admit(session, "Call blocked") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] yield* session.resume(sessionID) @@ -2831,8 +2837,7 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts runner continuation when permission approval is declined", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ declined: Tool.make({ @@ -2842,15 +2847,9 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new PermissionV2.DeclinedError()), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call declined" }), resume: false }) + yield* admit(session, "Call declined") - requests.length = 0 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ] + response = reply.tool("call-declined", "declined", {}) const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -2875,8 +2874,7 @@ describe("SessionRunnerLLM", () => { it.effect("returns permission corrections to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ corrected: Tool.make({ @@ -2889,22 +2887,9 @@ describe("SessionRunnerLLM", () => { ), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call corrected" }), resume: false }) + yield* admit(session, "Call corrected") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] yield* session.resume(sessionID) @@ -2924,20 +2909,10 @@ describe("SessionRunnerLLM", () => { it.effect("fails the drain when tool output persistence fails", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call storefail" }), resume: false }) + const session = yield* setup + yield* admit(session, "Call storefail") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + responses = [reply.tool("call-storefail", "storefail", {}), []] const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -2955,20 +2930,79 @@ describe("SessionRunnerLLM", () => { status: "error", error: { type: "unknown", - message: expect.stringContaining("Tool execution failed: Failed to encode tool output"), + message: expect.stringContaining("Failed to encode tool output"), + }, + }, + }, + ], + finish: "error", + error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") }, + }, + ]) + }), + ) + + it.effect("preserves permission rejection and stops before continuation", () => + Effect.gen(function* () { + const session = yield* setup + const registry = yield* ToolRegistry.Service + yield* registry.register({ + permissionfail: Tool.make({ + description: "Reject a permission", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + new ToolFailure({ + message: "Permission denied: edit", + error: new PermissionV2.BlockedError({ + rules: [], + permission: "edit", + resources: ["src/index.ts"], + }), + }), + }), + }) + yield* admit(session, "Reject permission") + responses = [ + reply.tool("call-permission", "permissionfail", {}), + [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })], + ] + + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect(requests).toHaveLength(1) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { + type: "assistant", + finish: "error", + error: { + type: "permission.rejected", + message: "Permission denied: edit", + }, + content: [ + { + type: "tool", + id: "call-permission", + state: { + status: "error", + error: { + type: "permission.rejected", + message: "Permission denied: edit", }, }, }, ], }, ]) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") }), ) it.effect("interrupts runner continuation when a question is cancelled", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ question: Tool.make({ @@ -2978,18 +3012,9 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new QuestionTool.CancelledError()), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Ask then stop" }), resume: false }) + yield* admit(session, "Ask then stop") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + responses = [reply.tool("call-question", "question", {}), []] const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild) const exit = yield* Fiber.join(run) @@ -3005,7 +3030,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-question", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, }, ], }, @@ -3015,9 +3040,8 @@ describe("SessionRunnerLLM", () => { it.effect("awaits started local tools before surfacing provider stream failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), resume: false }) + const session = yield* setup + yield* admit(session, "Settle before failing") const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3035,7 +3059,8 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) toolExecutionGate = undefined - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Settle before failing" }, { type: "assistant", @@ -3044,15 +3069,20 @@ describe("SessionRunnerLLM", () => { ], }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.failed.1", + ]) }), ) it.effect("durably fails blocked local tools when a provider turn is interrupted", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) - executions.length = 0 + const session = yield* setup + yield* admit(session, "Interrupt blocked tool") toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( Stream.fromIterable([ @@ -3069,7 +3099,8 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) yield* session.interrupt(sessionID) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Interrupt blocked tool" }, { type: "assistant", @@ -3077,11 +3108,18 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-before-interrupt", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, }, ], }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) yield* replaySessionProjection(sessionID) @@ -3099,11 +3137,8 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), resume: false }) - requests.length = 0 - response = [] + const session = yield* setup + yield* admit(session, "Interrupt provider") streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -3118,7 +3153,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Interrupt provider" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } }, + { type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } }, ]) expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1") yield* session.interrupt(sessionID) @@ -3127,19 +3162,12 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) - executions.length = 0 + const session = yield* setup + yield* admit(session, "Interrupt tool settlement") toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ] + response = reply.tool("call-await-interrupt", "echo", { text: "blocked" }) const runner = yield* SessionRunner.Service const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) @@ -3153,12 +3181,12 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "error", - error: { type: "unknown", message: "Step interrupted" }, + error: { type: "aborted", message: "Step interrupted" }, content: [ { type: "tool", id: "call-await-interrupt", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, }, ], }, @@ -3171,31 +3199,18 @@ describe("SessionRunnerLLM", () => { it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), resume: false }) + yield* admit(session, "Finish at the limit") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("call-terminal", "echo", { text: "done" }), + reply.tool("call-forbidden", "echo", { text: "forbidden" }), ] yield* session.resume(sessionID) @@ -3219,36 +3234,19 @@ describe("SessionRunnerLLM", () => { it.effect("resets the configured step allowance when steering input promotes", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start work" }), resume: false }) + yield* admit(session, "Start work") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], + reply.tool("call-before-steer", "echo", { text: "before" }), + reply.tool("call-after-steer", "echo", { text: "after" }), + reply.stop(), ] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -3271,52 +3269,114 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail durably" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail durably") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail durably" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } }, + { type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } }, ]) }), ) it.effect("projects provider errors emitted before assistant step start", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail before step" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail before step") - requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail before step" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } }, + { type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } }, ]) }), ) + it.effect("projects content-filter finishes as visible terminal failures", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Blocked response") + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "partial" }), + LLMEvent.textDelta({ id: "partial", text: "Partial" }), + LLMEvent.stepFinish({ + index: 0, + reason: "content-filter", + usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, + }), + LLMEvent.finish({ reason: "content-filter" }), + ] + + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response") + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { + type: "assistant", + finish: "error", + error: { type: "provider.content-filter" }, + cost: 0, + tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } }, + content: [{ type: "text", text: "Partial" }], + }, + ]) + expect(yield* session.get(sessionID)).toMatchObject({ + cost: 0, + tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } }, + }) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") + }), + ) + + it.effect("settles a local tool before one content-filter step failure", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Tool before blocked response") + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }), + LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + LLMEvent.finish({ reason: "content-filter" }), + ] + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(toolExecutionsStarted) + yield* Deferred.succeed(toolExecutionGate, undefined) + expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider blocked the response") + toolExecutionGate = undefined + toolExecutionsStarted = undefined + + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.failed.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) + }), + ) + it.effect("does not recover context overflow after durable assistant output", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail after output" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail after output") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-partial" }), @@ -3324,7 +3384,7 @@ describe("SessionRunnerLLM", () => { LLMEvent.textEnd({ id: "text-partial" }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ @@ -3341,155 +3401,392 @@ describe("SessionRunnerLLM", () => { it.effect("projects raw provider stream failures as terminal assistant step failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), resume: false }) - const failure = providerUnavailable() + const session = yield* setup + yield* admit(session, "Fail raw stream durably") + const failure = invalidRequest() responseStream = Stream.fail(failure) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail raw stream durably" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } }, + { type: "assistant", finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, ]) }), ) + it.effect("retries eligible pre-output failures after exponential backoff", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Retry transport") + responseStream = Stream.fail(providerUnavailable()) + response = reply.text("Recovered", "retry-success") + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + yield* TestClock.adjust("1999 millis") + expect(requests).toHaveLength(1) + yield* TestClock.adjust("1 millis") + yield* Fiber.join(run) + + expect(requests).toHaveLength(2) + const eventTypes = yield* recordedEventTypes(sessionID) + expect(eventTypes).toContain("session.retry.scheduled.1") + expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, + ]) + yield* replaySessionProjection(sessionID) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + }), + ) + + it.effect("uses a larger provider retry-after delay", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Retry rate limit") + responseStream = Stream.fail(rateLimited(5_000)) + response = reply.text("Recovered", "retry-after-success") + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + yield* TestClock.adjust("4999 millis") + expect(requests).toHaveLength(1) + yield* TestClock.adjust("1 millis") + yield* Fiber.join(run) + expect(requests).toHaveLength(2) + }), + ) + + it.effect("stops after five total retry attempts", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Exhaust retries") + streamFailure = providerUnavailable() + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) { + yield* TestClock.adjust(delay) + while (requests.length < index + 2) yield* Effect.yieldNow + } + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure) + expect(requests).toHaveLength(5) + + const database = (yield* Database.Service).db + const retries = yield* database + .select({ data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.type, "session.retry.scheduled.1")) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + expect(retries.map((event) => event.data)).toMatchObject([ + { attempt: 2, at: 2_000 }, + { attempt: 3, at: 6_000 }, + { attempt: 4, at: 14_000 }, + { attempt: 5, at: 30_000 }, + ]) + expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + }), + ) + + it.effect("counts retry attempts against the agent step allowance", () => + Effect.gen(function* () { + const session = yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + yield* admit(session, "Bound retries by steps") + const failure = providerUnavailable() + responseStream = Stream.fail(failure) + streamFailure = failure + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + yield* TestClock.adjust("2 seconds") + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) + + expect(requests).toHaveLength(2) + const eventTypes = yield* recordedEventTypes(sessionID) + expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2) + expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + }), + ) + + it.effect("does not retry non-eligible provider failures", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Do not retry") + const failure = invalidRequest() + streamFailure = failure + + expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(requests).toHaveLength(1) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1") + }), + ) + it.effect("does not continue automatically after a provider error follows a local tool call", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Do not continue failed provider" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Do not continue failed provider") - requests.length = 0 - const executionCount = executions.length + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }), LLMEvent.providerError({ message: "Provider unavailable" }), ] - yield* session.resume(sessionID) + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(toolExecutionsStarted) + yield* Deferred.succeed(toolExecutionGate, undefined) + expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider unavailable") + toolExecutionGate = undefined + toolExecutionsStarted = undefined expect(requests).toHaveLength(1) - expect(executions.slice(executionCount)).toEqual(["settled"]) + expect(executions).toEqual(["settled"]) + const context = yield* session.context(sessionID) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.failed.1", + ]) }), ) it.effect("durably fails a hosted tool when its provider errors before returning a result", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail hosted tool durably") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-provider-error", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-provider-error", "effect"), LLMEvent.providerError({ message: "Provider unavailable" }), ] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Fail hosted tool durably" }, { type: "assistant", content: [{ type: "tool", id: "call-hosted-provider-error", state: { status: "error" } }], }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + }), + ) + + it.effect("preserves a tool defect before provider failure settlement", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Defect while provider fails") + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), + LLMEvent.providerError({ message: "Provider unavailable" }), + ] + + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") + + const context = yield* session.context(sessionID) + const assistant = requireAssistant(context) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + expect(events[2]?.data.error).toMatchObject({ type: "unknown", message: "unexpected tool defect" }) }), ) it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-eof", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), - ] + const session = yield* setup + yield* admit(session, "Fail hosted tool at EOF") + response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result") + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail hosted tool at EOF" }, - { type: "assistant", content: [{ type: "tool", id: "call-hosted-eof", state: { status: "error" } }] }, + { + type: "assistant", + finish: "error", + error: { type: "tool.result-missing" }, + content: [{ type: "tool", id: "call-hosted-eof", state: { status: "error" } }], + }, ]) }), ) + it.effect("fails an unresolved hosted tool before one clean step end", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Settle hosted tool before ending") + response = [ + LLMEvent.stepStart({ index: 0 }), + hostedCall("call-hosted-clean-end", "effect"), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ] + + yield* session.resume(sessionID) + + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.ended.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) + }), + ) + + it.effect("settles unresolved local and hosted tools before one raw provider failure", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Fail unresolved tools") + const failure = invalidRequest() + const providerFailed = yield* Deferred.make() + toolExecutionGate = yield* Deferred.make() + responseStream = Stream.concat( + Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }), + hostedCall("call-hosted-raw-failure-pair", "effect"), + ]), + Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))), + ) + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(providerFailed) + yield* Deferred.succeed(toolExecutionGate, undefined) + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) + toolExecutionGate = undefined + + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => ({ type: event.type, callID: event.data.callID }))).toEqual([ + { type: "session.step.started.1", callID: undefined }, + { type: "session.tool.called.1", callID: "call-local-raw-failure" }, + { type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" }, + { type: "session.tool.failed.1", callID: "call-local-raw-failure" }, + { type: "session.tool.failed.1", callID: "call-hosted-raw-failure-pair" }, + { type: "session.step.failed.1", callID: undefined }, + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) + }), + ) + it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool on raw failure" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail hosted tool on raw failure") const failure = providerUnavailable() responseStream = Stream.concat( - Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-raw-failure", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), - ]), + Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]), Stream.fail(failure), ) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(requests).toHaveLength(1) + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail hosted tool on raw failure" }, { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.transport", message: "Provider unavailable" }, content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }], }, ]) }), ) - it.effect("keeps interleaved assistant text blocks separate", () => + it.effect("rejects a second text start before the open fragment ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) + const session = yield* setup + yield* admit(session, "Two blocks") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-2" }), + ] + + const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + expect(defect).toBeInstanceOf(Error) + if (!(defect instanceof Error)) return + expect(defect.message).toBe("text start before end: text-2") + }), + ) + + it.effect("projects sequential text fragments as separate content parts", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Two blocks") + + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-1" }), LLMEvent.textDelta({ id: "text-1", text: "First" }), - LLMEvent.textDelta({ id: "text-2", text: "Second" }), LLMEvent.textEnd({ id: "text-1" }), + LLMEvent.textStart({ id: "text-2" }), + LLMEvent.textDelta({ id: "text-2", text: "Second" }), LLMEvent.textEnd({ id: "text-2" }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -3502,8 +3799,8 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "text", id: "text-1", text: "First" }, - { type: "text", id: "text-2", text: "Second" }, + { type: "text", text: "First" }, + { type: "text", text: "Second" }, ], }, ]) @@ -3524,11 +3821,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects duplicate streamed text starts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - responses = undefined - streamGate = undefined - streamStarted = undefined + const session = yield* setup response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })] const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) @@ -3540,19 +3833,17 @@ describe("SessionRunnerLLM", () => { it.effect("transitions streamed raw tool input to parsed called input", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), resume: false }) + const session = yield* setup + yield* admit(session, "Call provider tool") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }), LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), - LLMEvent.toolCall({ id: "call-parsed", name: "web_search", input: { query: "hello" }, providerExecuted: true }), + hostedCall("call-parsed", "hello"), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), ] yield* session.resume(sessionID) @@ -3569,11 +3860,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects malformed streamed tool input ordering", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - responses = undefined - streamGate = undefined - streamStarted = undefined + const session = yield* setup response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })] const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 8f9803ef4a..d8cd7fbb56 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -26,7 +26,8 @@ const skills = Layer.mock(SkillV2.Service, { list: () => Effect.succeed([ SkillV2.Info.make({ - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Effect guidance", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Use Effect", @@ -60,10 +61,10 @@ describe("SessionV2.skill", () => { const session = yield* sessions.create({ location }) const id = SessionMessage.ID.make("msg_caller_skill") - yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false }) + yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false }) expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual( - expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }), + expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }), ) }), ) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index e730b3556b..e61bb54b63 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -4,6 +4,7 @@ import { DateTime, Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { AgentV2 } from "@opencode-ai/core/agent" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" @@ -51,7 +52,7 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model, }) const readAssistant = Effect.gen(function* () { @@ -76,9 +77,8 @@ describe("Tool.Progress", () => { sessionID, assistantMessageID, callID, - tool: "bash", input: { command: "pwd" }, - provider: { executed: false }, + executed: false, }) }) @@ -104,7 +104,7 @@ describe("Tool.Progress", () => { callID: "call-success", structured: { phase: "done" }, content: content("complete"), - provider: { executed: false }, + executed: false, }) expect((yield* readAssistant).content[0]).toMatchObject({ state: { status: "completed", structured: { phase: "done" }, content: content("complete") }, @@ -123,7 +123,7 @@ describe("Tool.Progress", () => { assistantMessageID, callID: "call-failed", error: { type: "unknown", message: "boom" }, - provider: { executed: false }, + executed: false, }) expect((yield* readAssistant).content[1]).toMatchObject({ state: { diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 9e48790dd0..100d44cfe2 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -76,6 +76,7 @@ test("Core reuses the canonical shared schemas", async () => { const schemas = [ [AgentV2.ID, Agent.ID], + [AgentV2.Name, Agent.Name], [AgentV2.Color, Agent.Color], [AgentV2.Info, Agent.Info], [coreCommand.Info, Command.Info], @@ -103,6 +104,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreIntegration.Ref, Integration.Ref], [coreLocation.Ref, Location.Ref], [coreLLM.ProviderMetadata, LLM.ProviderMetadata], + [coreLLM.FinishReason, LLM.FinishReason], [coreLLM.ToolTextContent, LLM.ToolTextContent], [coreLLM.ToolFileContent, LLM.ToolFileContent], [coreLLM.ToolContent, LLM.ToolContent], @@ -137,14 +139,14 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionInput.Delivery, SessionInput.Delivery], [coreSessionInput.Admitted, SessionInput.Admitted], [coreSessionMessage.ID, SessionMessage.ID], - [coreSessionMessage.UnknownError, SessionMessage.UnknownError], + [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected], [coreSessionMessage.User, SessionMessage.User], [coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.System, SessionMessage.System], [coreSessionMessage.Shell, SessionMessage.Shell], - [coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending], + [coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming], [coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning], [coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted], [coreSessionMessage.ToolStateError, SessionMessage.ToolStateError], @@ -155,7 +157,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.AssistantContent, SessionMessage.AssistantContent], [coreSessionMessage.Assistant, SessionMessage.Assistant], [coreSessionMessage.Compaction, SessionMessage.Compaction], - [coreSessionMessage.Message, SessionMessage.Message], + [coreSessionMessage.Info, SessionMessage.Info], [coreSessionTodo.Info, SessionTodo.Info], [coreSessionTodo.Event, SessionTodo.Event], [coreSkill.DirectorySource, Skill.DirectorySource], @@ -183,7 +185,7 @@ test("Core reuses the canonical shared schemas", async () => { test("shared record schemas construct and decode plain objects", () => { const made = Prompt.make({ text: "hello" }) const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) - const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" }) expect(Object.getPrototypeOf(made)).toBe(Object.prototype) expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index b9193023bf..1b8d846e69 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -88,13 +88,15 @@ describe("SkillV2", () => { ]) expect(yield* skill.list()).toEqual([ SkillV2.Info.make({ - name: "foo", + id: SkillV2.ID.make("foo"), + name: SkillV2.Name.make("foo"), slash: true, location: AbsolutePath.make(path.join(first, "foo.md")), content: "# foo", }), { - name: "review", + id: SkillV2.ID.make("review"), + name: SkillV2.Name.make("review"), description: "Second", location: AbsolutePath.make(path.join(second, "review", "SKILL.md")), content: "# review", @@ -129,8 +131,8 @@ describe("SkillV2", () => { const skill = yield* SkillV2.Service yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) - expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) - expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) + expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")]) + expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")]) expect(pulls).toBe(1) expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([]) }), @@ -165,7 +167,8 @@ metadata: expect(yield* skill.list()).toEqual([ { - name: "manual", + id: SkillV2.ID.make("manual"), + name: SkillV2.Name.make("manual"), description: "Manual only", slash: true, autoinvoke: false, diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 704f42b803..1c5b5b2839 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -11,24 +11,28 @@ import { it } from "../lib/effect" const build = AgentV2.ID.make("build") const effect = SkillV2.Info.make({ - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Build applications with Effect", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Effect guidance", }) const hidden = SkillV2.Info.make({ - name: "hidden", + id: SkillV2.ID.make("hidden"), + name: SkillV2.Name.make("Hidden"), location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")), content: "Undescribed guidance", }) const denied = SkillV2.Info.make({ - name: "denied", + id: SkillV2.ID.make("denied"), + name: SkillV2.Name.make("Denied"), description: "Must not be advertised", location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), content: "Denied guidance", }) const manual = SkillV2.Info.make({ - name: "manual", + id: SkillV2.ID.make("manual"), + name: SkillV2.Name.make("Manual"), description: "Load only when explicitly selected", autoinvoke: false, location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")), @@ -59,7 +63,8 @@ describe("SkillGuidance", () => { "Use the skill tool to load a skill when a task matches its description.", "", " ", - " effect", + " effect", + " Effect", " Build applications with Effect", " ", "", @@ -74,7 +79,7 @@ describe("SkillGuidance", () => { .pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))), ).toMatchObject({ _tag: "Updated", - text: "The following skills are no longer available and must not be used: effect.", + text: "The following skill IDs are no longer available and must not be used: effect.", }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -82,7 +87,8 @@ describe("SkillGuidance", () => { it.effect("announces added and removed skills as deltas without restating the list", () => { const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) const debugging = SkillV2.Info.make({ - name: "debugging", + id: SkillV2.ID.make("debugging"), + name: SkillV2.Name.make("Debugging"), description: "Diagnose hard bugs", location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")), content: "Debugging guidance", @@ -103,7 +109,8 @@ describe("SkillGuidance", () => { text: [ "New skills are available in addition to those previously listed:", " ", - " debugging", + " debugging", + " Debugging", " Diagnose hard bugs", " ", ].join("\n"), @@ -117,7 +124,7 @@ describe("SkillGuidance", () => { ) expect(removed).toMatchObject({ _tag: "Updated", - text: "The following skills are no longer available and must not be used: effect.", + text: "The following skill IDs are no longer available and must not be used: effect.", }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -192,7 +199,7 @@ describe("SkillGuidance", () => { const guidance = yield* SkillGuidance.Service expect( (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text, - ).toContain("effect") + ).toContain("Effect") }).pipe(Effect.provide(layer(() => [effect]))) }) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 5e01fefc17..3bc0f34101 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -56,7 +56,7 @@ describe("Snapshot", () => { const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) const preview = yield* snapshot.preview({ files: plan, context: 1 }) expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt")) yield* snapshot.restore({ files: plan }) expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 7481c087cc..ded236e654 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -40,7 +40,15 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-patch.test.ts similarity index 94% rename from packages/core/test/tool-apply-patch.test.ts rename to packages/core/test/tool-patch.test.ts index a97c3f662a..43b20d0cbf 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -13,20 +13,20 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" +import { PatchTool } from "@opencode-ai/core/tool/patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" -const applyPatchToolNode = makeLocationNode({ - name: "test/apply-patch-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)), +const patchToolNode = makeLocationNode({ + name: "test/patch-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], }) -const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") +const sessionID = SessionV2.ID.make("ses_patch_tool_test") const assertions: PermissionV2.AssertInput[] = [] let denyAction: string | undefined let failRemoveTarget: string | undefined @@ -47,7 +47,15 @@ const permission = Layer.succeed( }).pipe( Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void), Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), @@ -108,7 +116,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, - applyPatchToolNode, + patchToolNode, ]), [ [FSUtil.node, filesystem], @@ -121,13 +129,13 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ) } -const call = (patchText: string, id = "call-apply-patch") => ({ +const call = (patchText: string, id = "call-patch") => ({ sessionID, ...toolIdentity, - call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, + call: { type: "tool-call" as const, id, name: "patch", input: { patchText } }, }) -// apply_patch is only materialized for OpenAI/GPT models. +// patch is only materialized for OpenAI/GPT models. const model = { id: "gpt-5", provider: "openai" } const exists = (target: string) => @@ -139,7 +147,7 @@ const exists = (target: string) => ) const it = testEffect(Layer.empty) -describe("ApplyPatchTool", () => { +describe("PatchTool", () => { it.live("registers and sequentially applies add, update, and delete hunks", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -154,7 +162,7 @@ describe("ApplyPatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([ - "apply_patch", + "patch", ]) const settled = yield* settleTool( registry, @@ -233,7 +241,7 @@ describe("ApplyPatchTool", () => { ), model, ), - ).toEqual({ type: "error", value: "apply_patch moves are not supported yet" }) + ).toEqual({ type: "error", value: "patch moves are not supported yet" }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(assertions).toEqual([]) }), diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 4ac6f558a9..7afb2158c8 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -23,7 +23,17 @@ const permission = Layer.succeed( PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), + Effect.andThen( + deny + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), @@ -82,7 +92,13 @@ describe("QuestionTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, }), - ).toEqual({ result: { type: "error", value: "Permission denied: question" } }) + ).toEqual({ + result: { type: "error", value: "Permission denied: question" }, + error: { + type: "permission.rejected", + message: "Permission denied: question", + }, + }) expect(capturedInput()).toBeUndefined() deny = false }), diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 227be289e9..c5e7eaac48 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -81,7 +81,19 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => { assertions.push(input) - }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))), + }).pipe( + Effect.andThen( + allow + ? Effect.void + : Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ), + ), + ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index ab3926f3b9..6a6a53e171 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -2,7 +2,8 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" import path from "path" import { describe, expect, test } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Scope } from "effect" +import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -47,7 +48,15 @@ const permission = Layer.succeed( Effect.sync(() => assertions.push(input)).pipe( Effect.andThen(Effect.suspend(() => afterPermission(input))), Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), @@ -75,7 +84,6 @@ const executionNode = makeGlobalNode({ const session = yield* store.get(id) if (!session) return const assistantMessageID = SessionMessage.ID.create() - const textID = "text_shell_test" yield* events.publish(SessionEvent.Step.Started, { sessionID: id, assistantMessageID, @@ -85,19 +93,19 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Text.Started, { sessionID: id, assistantMessageID, - textID, + ordinal: 0, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID: id, assistantMessageID, - textID, + ordinal: 0, text: "ok", }) yield* events.publish(SessionEvent.Step.Ended, { sessionID: id, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) }) @@ -435,7 +443,7 @@ describe("ShellTool", () => { reset() return withSession(tmp.path, (registry) => Effect.gen(function* () { - const settled = yield* settleTool(registry, call({ command: idleCommand, background: true })) + const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true })) const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined expect(settled.output?.structured).toMatchObject({ truncated: false }) @@ -445,7 +453,45 @@ describe("ShellTool", () => { if (!shellID) return const id = ShellSchema.ID.make(shellID) expect((yield* shell.list()).map((info) => info.id)).toContain(id) - yield* shell.remove(id) + expect((yield* shell.wait(id)).status).toBe("timeout") + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + + it.live("updates and clears a running shell timeout", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const shell = yield* Shell.Service + const timed = yield* settleTool( + registry, + call({ command: idleCommand, background: true }, "call-updated-timeout"), + ) + const timedID = (timed.output?.structured as Record | undefined)?.shellID + expect(typeof timedID).toBe("string") + if (typeof timedID !== "string") return + const timedShellID = ShellSchema.ID.make(timedID) + yield* shell.timeout(timedShellID, 50) + expect((yield* shell.wait(timedShellID)).status).toBe("timeout") + + const cleared = yield* settleTool( + registry, + call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"), + ) + const clearedID = (cleared.output?.structured as Record | undefined)?.shellID + expect(typeof clearedID).toBe("string") + if (typeof clearedID !== "string") return + const clearedShellID = ShellSchema.ID.make(clearedID) + yield* shell.timeout(clearedShellID, 0) + yield* Effect.sleep(Duration.millis(100)) + expect((yield* shell.get(clearedShellID)).status).toBe("running") + yield* shell.remove(clearedShellID) }), ) }, @@ -462,9 +508,10 @@ describe("ShellTool", () => { Effect.gen(function* () { const jobs = yield* Job.Service const scope = yield* Scope.Scope - const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe( - Effect.forkIn(scope, { startImmediately: true }), - ) + const waiting = yield* settleTool( + registry, + call({ command: idleCommand, timeout: 50 }, "call-background-signal"), + ).pipe(Effect.forkIn(scope, { startImmediately: true })) const backgroundWhenReady = (remaining = 1000): Effect.Effect => Effect.gen(function* () { @@ -475,7 +522,6 @@ describe("ShellTool", () => { return yield* backgroundWhenReady(remaining - 1) }) expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }]) - const settled = yield* Fiber.join(waiting) const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined @@ -493,6 +539,8 @@ describe("ShellTool", () => { const shell = yield* Shell.Service if (!shellID) return const id = ShellSchema.ID.make(shellID) + yield* Effect.sleep(Duration.millis(100)) + expect((yield* shell.get(id)).status).toBe("running") expect((yield* shell.list()).map((info) => info.id)).toContain(id) yield* shell.remove(id) }), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index a9987154d8..8d81917d8c 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({ const sessionID = SessionV2.ID.make("ses_skill_tool_test") describe("SkillTool", () => { - it.live("lists available skills, authorizes the selected name, and loads model-facing content", () => + it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -42,7 +42,8 @@ describe("SkillTool", () => { ) const info: SkillV2.Info = { - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Use Effect", location: AbsolutePath.make(location), content: "# Effect\n\nGuidance", @@ -55,7 +56,17 @@ describe("SkillTool", () => { PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), + Effect.andThen( + deny + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), @@ -92,7 +103,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } }, }), ).toEqual({ type: "text", @@ -103,11 +114,11 @@ describe("SkillTool", () => { yield* settleTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, }), ).toMatchObject({ result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { structured: { name: "effect" } }, + output: { structured: { name: "Effect" } }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, @@ -117,7 +128,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, + call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } }, }), ).toEqual({ type: "error", value: "Unable to load skill missing" }) deny = true @@ -125,12 +136,13 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } }, }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) deny = false const flat = SkillV2.Info.make({ - name: "public", + id: SkillV2.ID.make("public"), + name: SkillV2.Name.make("Public"), description: "Public guidance", location: AbsolutePath.make(path.join(tmp.path, "public.md")), content: "Public", @@ -146,7 +158,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, + call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) }).pipe(Effect.provide(skillToolLayer)) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 4dd46da691..55223a9daa 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -19,6 +20,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { SubagentTool } from "@opencode-ai/core/tool/subagent" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -49,7 +51,6 @@ const executionNode = makeGlobalNode({ } completed.add(sessionID) const assistantMessageID = SessionMessage.ID.create() - const textID = "text_subagent_test" yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, @@ -59,19 +60,19 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, - textID, + ordinal: 0, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, - textID, + ordinal: 0, text: childText, }) yield* events.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens, }) }) @@ -106,8 +107,14 @@ const it = testEffect(layer) const withSubagent = (location: Location.Ref) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location))) yield* AgentV2.Service.use((agents) => agents.transform((draft) => { + // The caller identity used by executeTool; subagent permission asserts against it. + draft.update(toolIdentity.agent, (agent) => { + agent.mode = "primary" + agent.permissions.push({ action: "*", resource: "*", effect: "allow" }) + }) draft.update(AgentV2.ID.make("reviewer"), (agent) => { agent.mode = "subagent" agent.model = childModel diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 1e967c5fcf..055df2fbc7 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -33,7 +33,17 @@ const permission = Layer.succeed( PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), + Effect.andThen( + deny + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 6bd4f2b78e..1fc82f1428 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -38,7 +38,15 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index a0f737a998..ca2820737d 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -134,6 +134,29 @@ describe("util.effect-flock", () => { }), ) + it.live( + "supports an acquisition timeout", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:timeout" + + yield* Effect.scoped( + Effect.gen(function* () { + yield* flock.acquire(key, dir) + const started = performance.now() + const error = yield* Effect.scoped( + flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 }), + ).pipe(Effect.flip) + expect(error._tag).toBe("LockTimeoutError") + expect(performance.now() - started).toBeLessThan(1_000) + }), + ) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + it.live( "withLock data-first", Effect.gen(function* () { diff --git a/packages/docs/AGENTS.md b/packages/docs/AGENTS.md new file mode 100644 index 0000000000..8d82c52655 --- /dev/null +++ b/packages/docs/AGENTS.md @@ -0,0 +1,22 @@ +# V2 documentation guide + +## Structure + +- This directory is a standalone Mintlify site deployed from `packages/docs` on the `dev` branch. +- Write documentation in MDX. Every page should have `title` and `description` frontmatter. +- `docs.json` owns site configuration and navigation. Add, move, or remove its page entries whenever the corresponding MDX pages change. +- Put static files in `assets/` and reference them with root-relative paths such as `/assets/example.svg`. +- The API endpoint reference is generated by Mintlify from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX. +- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1. + +## Local development + +- At the start of documentation work, launch `bun dev` from `packages/docs` using the shell tool with `background: true`. Never run the dev server in a foreground shell call and do not poll the process; wait for the background completion notification. +- Preview the site at `http://localhost:3333`. Mintlify does not expose a host option and binds the preview to all network interfaces. The server reloads changes to MDX and `docs.json` automatically. +- Use the running preview to verify navigation, links, Mintlify components, code blocks, and desktop and mobile layout. + +## Validation + +- Run `bun validate` from `packages/docs` after making documentation or configuration changes. +- Run `bun broken-links` from `packages/docs` when pages, navigation, headings, or links change. +- Treat validation errors and broken internal links as blockers. Also verify external links relevant to the change when practical. diff --git a/packages/docs/README.md b/packages/docs/README.md index 792f64b3f8..17b06848e1 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -4,21 +4,19 @@ The V2 documentation is a Mintlify site deployed from `packages/docs` on the `de ## Local preview -The Mintlify CLI requires Node.js 20 through 24. - From this directory, run: ```bash -npx mint dev +bun dev ``` -The preview opens at `http://localhost:3000` and reloads when MDX or `docs.json` changes. +The preview opens at `http://localhost:3333` and reloads when MDX or `docs.json` changes. Validate changes before opening a pull request: ```bash -npx mint validate -npx mint broken-links +bun validate +bun broken-links ``` The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site). diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx index 42028fd726..09294d094a 100644 --- a/packages/docs/config.mdx +++ b/packages/docs/config.mdx @@ -6,3 +6,439 @@ description: "Configure OpenCode." You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you. + +## Format + +OpenCode supports both **JSON** and **JSONC** (JSON with Comments) configuration files. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "model": "openai/gpt-5.2-custom", + "providers": { + "openai": { + "models": { + "gpt-5.2-custom": { + "modelID": "gpt-5.2", + "name": "GPT-5.2 Custom" + } + } + } + } +} +``` + +## Locations + +OpenCode loads global configuration from: + +```text +~/.config/opencode/opencode.json(c) +``` + +Project-specific configuration can use either form: + +```text +/home/user/projects/my-app/opencode.json(c) +/home/user/projects/my-app/.opencode/opencode.json(c) +``` + +When OpenCode starts, it searches for configuration files from the current +directory upward to the project root. The files are merged, and configuration +closer to the current directory takes precedence. + +For example, consider a monorepo with OpenCode started from +`/home/user/projects/acme/packages/web`: + +```text +~/.config/opencode/opencode.json + +/home/user/projects/acme/ +├── opencode.json +└── packages/ + └── web/ + ├── opencode.json + └── src/ +``` + +OpenCode applies these files from lowest to highest precedence: + +1. `~/.config/opencode/opencode.json` +2. `/home/user/projects/acme/opencode.json` +3. `/home/user/projects/acme/packages/web/opencode.json` + +Settings in the package config override matching settings from the repository +config, which override matching settings from the global config. Settings that +do not conflict are preserved from every file. + +## Schema + +The complete OpenCode configuration schema is available at +[opencode.ai/config.json](https://opencode.ai/config.json). + +Add the `$schema` field to your configuration file to enable validation and +autocomplete in editors that support JSON Schema: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json" +} +``` + +Use the schema as the source of truth for available fields, accepted values, +and nested configuration shapes. + +### Shell + +Set the shell used by the terminal and shell tools. + +```jsonc +{ + "shell": "/bin/zsh" +} +``` + +### Model + +Set the default model in `provider/model` format. Add `#variant` to select a +specific model variant. + +```jsonc +{ + "model": "anthropic/claude-sonnet-4-5#high" +} +``` + +See the [models guide](https://opencode.ai/docs/models/) for model selection +and local models. + +### Default agent + +Choose the primary agent used when a session does not select one explicitly. + +```jsonc +{ + "default_agent": "build" +} +``` + +See the [agents guide](https://opencode.ai/docs/agents/) for built-in and custom +agents. + +### Autoupdate + +Control automatic updates. Set this to `false` to disable updates or `"notify"` +to receive update notifications. + +```jsonc +{ + "autoupdate": false +} +``` + +### Sharing + +Control whether sessions can be shared manually, shared automatically, or not +shared at all. + +```jsonc +{ + "share": "manual" +} +``` + +See the [sharing guide](https://opencode.ai/docs/share/) for more details. + +### Username + +Set the username displayed in conversations. + +```jsonc +{ + "username": "alice" +} +``` + +### Permissions + +Define ordered rules that allow, deny, or ask before an agent uses a tool on a +matching resource. + +```jsonc +{ + "permissions": [ + { + "action": "bash", + "resource": "git push *", + "effect": "ask" + } + ] +} +``` + +See the [permissions guide](https://opencode.ai/docs/permissions/) for rule +matching and available actions. + +### Agents + +Override built-in agents or define specialized agents with their own model, +instructions, mode, and permissions. + +```jsonc +{ + "agents": { + "reviewer": { + "description": "Review changes without editing files", + "mode": "subagent", + "system": "Focus on correctness, security, and missing tests.", + "permissions": [ + { "action": "edit", "resource": "*", "effect": "deny" } + ] + } + } +} +``` + +See the [agents guide](https://opencode.ai/docs/agents/) for all agent options +and file-based agents. + +### Snapshots + +Enable or disable the snapshots used by undo and revert behavior. + +```jsonc +{ + "snapshots": false +} +``` + +### Watcher + +Ignore files and directories that should not trigger filesystem updates. + +```jsonc +{ + "watcher": { + "ignore": ["dist/**", "coverage/**"] + } +} +``` + +### Formatter + +Enable built-in formatters, disable formatting entirely, or configure formatter +commands by name. + +```jsonc +{ + "formatter": { + "prettier": { + "command": ["bunx", "prettier", "--write", "$FILE"], + "extensions": [".js", ".ts", ".tsx"] + } + } +} +``` + +See the [formatters guide](https://opencode.ai/docs/formatters/) for built-in +formatters and custom commands. + +### LSP + +Enable built-in language servers, disable them, or configure servers by name. + +```jsonc +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx"] + } + } +} +``` + +See the [LSP guide](https://opencode.ai/docs/lsp/) for language server setup. + +### Attachments + +Control how oversized image attachments are resized or rejected before they are +sent to a model. + +```jsonc +{ + "attachments": { + "image": { + "auto_resize": true, + "max_width": 2000, + "max_height": 2000, + "max_base64_bytes": 5242880 + } + } +} +``` + +### Tool output + +Set the maximum number of lines and bytes retained from a tool result. + +```jsonc +{ + "tool_output": { + "max_lines": 2000, + "max_bytes": 51200 + } +} +``` + +### MCP + +Configure local and remote Model Context Protocol servers. Global timeouts can +be overridden by an individual server. + +```jsonc +{ + "mcp": { + "servers": { + "playwright": { + "type": "local", + "command": ["bunx", "@playwright/mcp"] + } + } + } +} +``` + +See the [MCP guide](https://opencode.ai/docs/mcp-servers/) for remote servers, +OAuth, environment variables, and timeouts. + +### Compaction + +Control automatic context compaction and how much recent context it preserves. + +```jsonc +{ + "compaction": { + "auto": true, + "keep": { + "tokens": 8000 + }, + "buffer": 20000 + } +} +``` + +### Skills + +Add directories or URLs that OpenCode should search for agent skills. + +```jsonc +{ + "skills": ["./team-skills", "https://example.com/.well-known/skills/"] +} +``` + +See the [skills guide](https://opencode.ai/docs/skills/) for skill structure and +automatic discovery under `.opencode/skills/`. + +### Commands + +Define reusable slash commands as named prompt templates. + +```jsonc +{ + "commands": { + "review": { + "description": "Review the current changes", + "template": "Review the current diff for correctness and missing tests." + } + } +} +``` + +See the [commands guide](https://opencode.ai/docs/commands/) for arguments, +models, agents, and file-based commands. + +### Instructions + +Load additional instruction files, globs, or URLs into the agent's context. + +```jsonc +{ + "instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md"] +} +``` + +See the [rules guide](https://opencode.ai/docs/rules/) for project instructions +and `AGENTS.md`. + +### References + +Make local directories or Git repositories available as named supporting +context. + +```jsonc +{ + "references": { + "docs": { + "path": "../product-docs", + "description": "Product behavior and terminology" + }, + "effect": { + "repository": "Effect-TS/effect", + "branch": "main" + } + } +} +``` + +See the [references guide](https://opencode.ai/docs/references/) for shorthand, +visibility, and path resolution. + +### Plugins + +Load plugins from packages or local files. Use the object form when a plugin +accepts options. + +```jsonc +{ + "plugins": [ + "opencode-example-plugin", + { + "package": "./plugins/local.ts", + "options": { + "enabled": true + } + } + ] +} +``` + +See the [plugins guide](/plugins) for plugin development and configuration. + +### Providers + +Configure providers and add or override their models, request settings, +headers, and model variants. + +```jsonc +{ + "providers": { + "openai": { + "models": { + "gpt-5.2-custom": { + "modelID": "gpt-5.2", + "name": "GPT-5.2 Custom", + "limit": { + "context": 200000, + "output": 32000 + } + } + } + } + } +} +``` + +See the [providers guide](https://opencode.ai/docs/providers/) for credentials, +custom endpoints, provider packages, and model configuration. diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 68a975cf85..e8559d72f6 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,7 +17,12 @@ "tabs": [ { "tab": "Docs", - "pages": ["index", "config", "plugins", "troubleshooting"] + "groups": [ + { + "group": "Get started", + "pages": ["index", "config", "plugins", "troubleshooting"] + } + ] }, { "tab": "SDK", @@ -39,19 +44,6 @@ ], "global": {} }, - "navbar": { - "links": [ - { - "label": "Discord", - "href": "https://opencode.ai/discord" - } - ], - "primary": { - "type": "button", - "label": "GitHub", - "href": "https://github.com/anomalyco/opencode" - } - }, "contextual": { "options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"] }, diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index bcad31bd32..c2cd5a1d21 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -27,10 +27,23 @@ "enum": [ true ] + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] } }, "required": [ - "healthy" + "healthy", + "version", + "pid" ], "additionalProperties": false } @@ -210,7 +223,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/AgentV2.Info" + "$ref": "#/components/schemas/Agent.Info" } } }, @@ -582,7 +595,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -699,14 +712,10 @@ "$ref": "#/components/schemas/SessionActive" } } - }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" } }, "required": [ - "data", - "watermarks" + "data" ], "additionalProperties": false } @@ -734,7 +743,7 @@ } } }, - "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", "summary": "List active sessions" } }, @@ -769,7 +778,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -820,6 +829,72 @@ }, "description": "Retrieve a session by ID.", "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" } }, "/api/session/{sessionID}/fork": { @@ -853,7 +928,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -1198,6 +1273,119 @@ } } }, + "/api/session/{sessionID}/move": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.move", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move a session to another project directory, optionally transferring local changes.", + "summary": "Move session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "moveChanges": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destination" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": [ @@ -1245,7 +1433,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1397,7 +1592,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1759,6 +1961,16 @@ }, "metadata": { "type": "object" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1897,8 +2109,24 @@ ], "security": [], "responses": { - "204": { - "description": "" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Compaction" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } }, "400": { "description": "InvalidRequestError", @@ -1938,38 +2166,46 @@ } }, "409": { - "description": "SessionBusyError", + "description": "ConflictError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionBusyError" - } - } - } - }, - "500": { - "description": "UnknownError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnknownError" - } - } - } - }, - "503": { - "description": "ServiceUnavailableError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableError" + "$ref": "#/components/schemas/ConflictError" } } } } }, - "description": "Compact a session conversation.", - "summary": "Compact session" + "description": "Queue a durable session compaction request.", + "summary": "Compact session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } } }, "/api/session/{sessionID}/wait": { @@ -2081,7 +2317,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -2388,7 +2624,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } } }, @@ -2452,12 +2688,12 @@ "summary": "Get session context" } }, - "/api/session/{sessionID}/context-entry": { + "/api/session/{sessionID}/instructions/entries": { "get": { "tags": [ "session" ], - "operationId": "v2.session.context.entry.list", + "operationId": "v2.session.instructions.entry.list", "parameters": [ { "name": "sessionID", @@ -2485,7 +2721,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionContextEntry.Info" + "$ref": "#/components/schemas/InstructionEntry.Info" } } }, @@ -2535,16 +2771,16 @@ } } }, - "description": "List API-managed context entries attached to the session's system context.", - "summary": "List context entries" + "description": "List API-managed instruction entries attached to the session.", + "summary": "List instruction entries" } }, - "/api/session/{sessionID}/context-entry/{key}": { + "/api/session/{sessionID}/instructions/entries/{key}": { "put": { "tags": [ "session" ], - "operationId": "v2.session.context.entry.put", + "operationId": "v2.session.instructions.entry.put", "parameters": [ { "name": "sessionID", @@ -2563,7 +2799,7 @@ "name": "key", "in": "path", "schema": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "required": true } @@ -2611,8 +2847,8 @@ } } }, - "description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", - "summary": "Put context entry", + "description": "Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.", + "summary": "Put instruction entry", "requestBody": { "content": { "application/json": { @@ -2635,7 +2871,7 @@ "tags": [ "session" ], - "operationId": "v2.session.context.entry.remove", + "operationId": "v2.session.instructions.entry.remove", "parameters": [ { "name": "sessionID", @@ -2654,7 +2890,7 @@ "name": "key", "in": "path", "schema": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "required": true } @@ -2702,11 +2938,11 @@ } } }, - "description": "Remove one context entry; the removal is announced to the model at the next turn boundary.", - "summary": "Remove context entry" + "description": "Remove one instruction entry; the removal is announced to the model at the next step boundary.", + "summary": "Remove instruction entry" } }, - "/api/session/{sessionID}/log": { + "/api/experimental/session/{sessionID}/log": { "get": { "tags": [ "session" @@ -2911,7 +3147,7 @@ } } }, - "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "description": "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.", "summary": "Read the session log" } }, @@ -3095,7 +3331,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "required": [ @@ -3156,7 +3392,7 @@ "tags": [ "session" ], - "operationId": "v2.session.messages", + "operationId": "v2.message.list", "parameters": [ { "name": "sessionID", @@ -3358,7 +3594,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" } } }, @@ -3469,7 +3705,7 @@ "data": { "anyOf": [ { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" }, { "type": "null" @@ -4781,6 +5017,104 @@ "summary": "List MCP servers" } }, + "/api/mcp/resource": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.resource.catalog", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Mcp.ResourceCatalog" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve resources and resource templates from connected MCP servers.", + "summary": "List MCP resources" + } + }, "/api/credential/{credentialID}": { "patch": { "tags": [ @@ -6978,7 +7312,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CommandV2.Info" + "$ref": "#/components/schemas/Command.Info" } } }, @@ -7079,7 +7413,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SkillV2.Info" + "$ref": "#/components/schemas/Skill.Info" } } }, @@ -7257,154 +7591,10 @@ } } }, - "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.", "summary": "Subscribe to events" } }, - "/api/event/changes": { - "get": { - "tags": [ - "event" - ], - "operationId": "v2.event.changes", - "parameters": [], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "event": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/EventLog.ChangeStream" - } - }, - "required": [ - "id", - "event", - "data" - ], - "additionalProperties": false - }, - "x-effect-stream": { - "encoding": "sse", - "causeSchema": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Fail" - ] - }, - "error": { - "not": {} - } - }, - "required": [ - "_tag", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Die" - ] - }, - "defect": {} - }, - "required": [ - "_tag", - "defect" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Interrupt" - ] - }, - "fiberId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "fiberId" - ], - "additionalProperties": false - } - ] - } - }, - "errorSchema": { - "not": {} - }, - "failureEvent": "effect/httpapi/stream/failure" - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - } - }, - "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", - "summary": "Subscribe to change hints" - } - }, "/api/pty": { "get": { "tags": [ @@ -8022,7 +8212,7 @@ "tags": [ "pty" ], - "operationId": "v2.pty.connectToken", + "operationId": "v2.pty.connect.token", "parameters": [ { "name": "ptyID", @@ -8318,7 +8508,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } } }, @@ -8415,7 +8605,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8475,7 +8665,8 @@ } }, "required": [ - "command" + "command", + "timeout" ], "additionalProperties": false } @@ -8559,7 +8750,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8705,6 +8896,151 @@ "summary": "Remove shell command" } }, + "/api/shell/{id}/timeout": { + "patch": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.timeout", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Replace a running shell command's timeout from now, or clear it with zero.", + "summary": "Update shell timeout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "timeout" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/shell/{id}/output": { "get": { "tags": [ @@ -9864,7 +10200,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -9907,7 +10243,7 @@ "tags": [ "debug" ], - "operationId": "v2.debug.location", + "operationId": "v2.debug.location.list", "parameters": [], "security": [], "responses": { @@ -9947,6 +10283,82 @@ }, "description": "List locations currently loaded by the server.", "summary": "List loaded locations" + }, + "delete": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.evict", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Dispose the requested location's cached services so its next use boots them fresh.", + "summary": "Evict a loaded location" } } }, @@ -10150,12 +10562,15 @@ "$ref": "#/components/schemas/PermissionV2.Rule" } }, - "AgentV2.Info": { + "Agent.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "name": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, @@ -10196,6 +10611,7 @@ }, "required": [ "id", + "name", "request", "mode", "hidden", @@ -10215,6 +10631,46 @@ ], "additionalProperties": false }, + "Money.USD": { + "type": "number" + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, "Location.Ref": { "type": "object", "properties": { @@ -10235,19 +10691,14 @@ ], "additionalProperties": false }, - "File.Diff": { + "FileDiff.Info": { "type": "object", "properties": { - "path": { + "file": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "added", - "modified", - "deleted" - ] + "patch": { + "type": "string" }, "additions": { "type": "integer", @@ -10265,20 +10716,25 @@ } ] }, - "patch": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ - "path", - "status", + "file", + "patch", "additions", "deletions", - "patch" + "status" ], "additionalProperties": false }, - "Revert.State": { + "Session.Revert": { "type": "object", "properties": { "messageID": { @@ -10295,13 +10751,10 @@ "snapshot": { "type": "string" }, - "diff": { - "type": "string" - }, "files": { "type": "array", "items": { - "$ref": "#/components/schemas/File.Diff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10310,7 +10763,7 @@ ], "additionalProperties": false }, - "SessionV2.Info": { + "Session.Info": { "type": "object", "properties": { "id": { @@ -10329,6 +10782,31 @@ } ] }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + }, "projectID": { "type": "string" }, @@ -10339,44 +10817,10 @@ "$ref": "#/components/schemas/Model.Ref" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "time": { "type": "object", @@ -10407,7 +10851,7 @@ "type": "string" }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -10421,32 +10865,15 @@ ], "additionalProperties": false }, - "SessionWatermarks": { - "type": "object", - "patternProperties": { - "^ses": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." - }, "SessionsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" - }, "cursor": { "type": "object", "properties": { @@ -10476,7 +10903,6 @@ }, "required": [ "data", - "watermarks", "cursor" ], "additionalProperties": false @@ -10604,7 +11030,7 @@ ], "additionalProperties": false }, - "Prompt.Source": { + "Prompt.Mention": { "type": "object", "properties": { "start": { @@ -10636,8 +11062,8 @@ "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10651,8 +11077,8 @@ "name": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10684,28 +11110,78 @@ ], "additionalProperties": false }, + "Prompt.Base64": { + "type": "string", + "allOf": [ + { + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + ] + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, "Prompt.FileAttachment": { "type": "object", "properties": { - "uri": { - "type": "string" + "data": { + "$ref": "#/components/schemas/Prompt.Base64" }, "mime": { "type": "string" }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, "name": { "type": "string" }, "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ - "uri", - "mime" + "data", + "mime", + "source" ], "additionalProperties": false }, @@ -10890,26 +11366,57 @@ ], "additionalProperties": false }, - "SessionBusyError": { + "SessionInput.Compaction": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", "enum": [ - "SessionBusyError" + "compaction" + ] + }, + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } ] }, "sessionID": { - "type": "string" + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "message": { - "type": "string" + "timeCreated": { + "type": "number" + }, + "handledSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "_tag", + "type", + "admittedSeq", + "id", "sessionID", - "message" + "timeCreated" ], "additionalProperties": false }, @@ -10942,6 +11449,29 @@ ], "additionalProperties": false }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, "UnknownError": { "type": "object", "properties": { @@ -11144,14 +11674,6 @@ ], "additionalProperties": false }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, "text": { "type": "string" }, @@ -11168,7 +11690,6 @@ "required": [ "id", "time", - "sessionID", "text", "type" ], @@ -11250,6 +11771,9 @@ "skill" ] }, + "skill": { + "type": "string" + }, "name": { "type": "string" }, @@ -11261,187 +11785,12 @@ "id", "time", "type", + "skill", "name", "text" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "Session.Message.Shell": { "type": "object", "properties": { @@ -11477,8 +11826,62 @@ "shell" ] }, - "shell": { - "$ref": "#/components/schemas/Shell" + "shellID": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "command": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] }, "output": { "type": "object", @@ -11519,7 +11922,9 @@ "id", "time", "type", - "shell" + "shellID", + "command", + "status" ], "additionalProperties": false }, @@ -11532,25 +11937,18 @@ "text" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" } }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "LLM.ProviderMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState": { + "type": "object" }, "Session.Message.Assistant.Reasoning": { "type": "object", @@ -11561,14 +11959,11 @@ "reasoning" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "time": { "type": "object", @@ -11588,18 +11983,17 @@ }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "Session.Message.ToolState.Pending": { + "Session.Message.ToolState.Streaming": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "pending" + "streaming" ] }, "input": { @@ -11709,24 +12103,12 @@ "input": { "type": "object" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, "content": { "type": "array", "items": { "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -11740,14 +12122,11 @@ ], "additionalProperties": false }, - "Session.Error.Unknown": { + "Session.StructuredError": { "type": "object", "properties": { "type": { - "type": "string", - "enum": [ - "unknown" - ] + "type": "string" }, "message": { "type": "string" @@ -11781,7 +12160,7 @@ "type": "object" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {} }, @@ -11809,28 +12188,19 @@ "name": { "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - }, - "resultMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "state": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" }, { "$ref": "#/components/schemas/Session.Message.ToolState.Running" @@ -11854,9 +12224,6 @@ }, "completed": { "type": "number" - }, - "pruned": { - "type": "number" } }, "required": [ @@ -11874,6 +12241,31 @@ ], "additionalProperties": false }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, "Session.Message.Assistant": { "type": "object", "properties": { @@ -11950,50 +12342,27 @@ "additionalProperties": false }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" } }, "required": [ @@ -12006,7 +12375,7 @@ ], "additionalProperties": false }, - "Session.Message.Compaction": { + "Session.Message.Compaction.Running": { "type": "object", "properties": { "type": { @@ -12015,19 +12384,6 @@ "compaction" ] }, - "reason": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "summary": { - "type": "string" - }, - "recent": { - "type": "string" - }, "id": { "type": "string", "allOf": [ @@ -12050,19 +12406,174 @@ "created" ], "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" } }, "required": [ "type", + "id", + "time", + "status", "reason", "summary", - "recent", - "id", - "time" + "recent" ], "additionalProperties": false }, - "Session.Message": { + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Compaction.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Info": { "anyOf": [ { "$ref": "#/components/schemas/Session.Message.AgentSelected" @@ -12093,20 +12604,20 @@ } ] }, - "SessionContextEntry.Key": { + "InstructionEntry.Key": { "type": "string", "allOf": [ { "pattern": "^[a-z0-9][a-z0-9._-]*$", - "description": "Context entry key (lowercase alphanumerics plus . _ -)" + "description": "Instruction entry key (lowercase alphanumerics plus . _ -)" } ] }, - "SessionContextEntry.Info": { + "InstructionEntry.Info": { "type": "object", "properties": { "key": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "value": {} }, @@ -12154,11 +12665,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12241,11 +12750,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12328,11 +12835,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12418,11 +12923,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12467,6 +12970,87 @@ ], "additionalProperties": false }, + "session.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 2 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.forked": { "type": "object", "properties": { @@ -12505,11 +13089,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12605,11 +13187,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12697,11 +13277,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12763,7 +13341,7 @@ ], "additionalProperties": false }, - "session.context.updated": { + "session.execution.started": { "type": "object", "properties": { "id": { @@ -12783,7 +13361,7 @@ "type": { "type": "string", "enum": [ - "session.context.updated" + "session.execution.started" ] }, "durable": { @@ -12801,12 +13379,347 @@ ] }, "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.succeeded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.succeeded" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { "type": "integer", "allOf": [ { - "minimum": 1 + "minimum": 0 } ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "sessionID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.interrupted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.interrupted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "user", + "shutdown", + "superseded" + ] + } + }, + "required": [ + "sessionID", + "reason" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.instructions.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.instructions.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] } }, "required": [ @@ -12888,11 +13801,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12981,11 +13892,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13010,6 +13919,9 @@ } ] }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -13019,6 +13931,7 @@ }, "required": [ "sessionID", + "id", "name", "text" ], @@ -13034,7 +13947,7 @@ ], "additionalProperties": false }, - "Shell1": { + "Shell": { "type": "object", "properties": { "id": { @@ -13212,11 +14125,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13242,7 +14153,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -13299,11 +14210,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13329,7 +14238,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" }, "output": { "type": "object", @@ -13421,11 +14330,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13524,11 +14431,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13562,47 +14467,21 @@ ] }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "snapshot": { "type": "string" @@ -13671,11 +14550,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13709,7 +14586,13 @@ ] }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ @@ -13767,11 +14650,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13804,14 +14685,19 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ "sessionID", "assistantMessageID", - "textID" + "ordinal" ], "additionalProperties": false } @@ -13863,11 +14749,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13900,8 +14784,13 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" @@ -13910,7 +14799,7 @@ "required": [ "sessionID", "assistantMessageID", - "textID", + "ordinal", "text" ], "additionalProperties": false @@ -13925,11 +14814,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata3": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState3": { + "type": "object" }, "session.reasoning.started": { "type": "object", @@ -13969,11 +14855,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14006,17 +14890,22 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata3" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState3" } }, "required": [ "sessionID", "assistantMessageID", - "reasoningID" + "ordinal" ], "additionalProperties": false } @@ -14030,11 +14919,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata4": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState4": { + "type": "object" }, "session.reasoning.ended": { "type": "object", @@ -14074,11 +14960,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14111,20 +14995,25 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata4" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState4" } }, "required": [ "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "text" ], "additionalProperties": false @@ -14177,11 +15066,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14277,11 +15164,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14339,11 +15224,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata5": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState5": { + "type": "object" }, "session.tool.called": { "type": "object", @@ -14383,11 +15265,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14423,35 +15303,22 @@ "callID": { "type": "string" }, - "tool": { - "type": "string" - }, "input": { "type": "object" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata5" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState5" } }, "required": [ "sessionID", "assistantMessageID", "callID", - "tool", "input", - "provider" + "executed" ], "additionalProperties": false } @@ -14503,11 +15370,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14572,11 +15437,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata6": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState6": { + "type": "object" }, "session.tool.success": { "type": "object", @@ -14616,11 +15478,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14665,27 +15525,12 @@ "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata6" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState6" } }, "required": [ @@ -14694,7 +15539,7 @@ "callID", "structured", "content", - "provider" + "executed" ], "additionalProperties": false } @@ -14708,11 +15553,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata7": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState7": { + "type": "object" }, "session.tool.failed": { "type": "object", @@ -14752,11 +15594,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14793,23 +15633,14 @@ "type": "string" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata7" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState7" } }, "required": [ @@ -14817,7 +15648,7 @@ "assistantMessageID", "callID", "error", - "provider" + "executed" ], "additionalProperties": false } @@ -14831,41 +15662,7 @@ ], "additionalProperties": false }, - "session.retry.error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - }, - "session.retried": { + "session.retry.scheduled": { "type": "object", "properties": { "id": { @@ -14885,7 +15682,7 @@ "type": { "type": "string", "enum": [ - "session.retried" + "session.retry.scheduled" ] }, "durable": { @@ -14903,11 +15700,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14932,16 +15727,39 @@ } ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, "attempt": { - "type": "number" + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "error": { - "$ref": "#/components/schemas/session.retry.error" + "$ref": "#/components/schemas/Session.StructuredError" } }, "required": [ "sessionID", + "assistantMessageID", "attempt", + "at", "error" ], "additionalProperties": false @@ -14956,6 +15774,96 @@ ], "additionalProperties": false }, + "session.compaction.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.compaction.started": { "type": "object", "properties": { @@ -14994,11 +15902,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15029,11 +15935,23 @@ "auto", "manual" ] + }, + "recent": { + "type": "string" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ "sessionID", - "reason" + "reason", + "recent" ], "additionalProperties": false } @@ -15085,11 +16003,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15146,6 +16062,107 @@ ], "additionalProperties": false }, + "session.compaction.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "reason", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.revert.staged": { "type": "object", "properties": { @@ -15184,11 +16201,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15214,7 +16229,7 @@ ] }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -15271,11 +16286,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15354,11 +16367,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15383,7 +16394,7 @@ } ] }, - "messageID": { + "to": { "type": "string", "allOf": [ { @@ -15394,7 +16405,7 @@ }, "required": [ "sessionID", - "messageID" + "to" ], "additionalProperties": false } @@ -15408,7 +16419,7 @@ ], "additionalProperties": false }, - "SessionDurableEvent": { + "Session.Event.Durable": { "oneOf": [ { "$ref": "#/components/schemas/session.agent.selected" @@ -15422,6 +16433,9 @@ { "$ref": "#/components/schemas/session.renamed" }, + { + "$ref": "#/components/schemas/session.deleted" + }, { "$ref": "#/components/schemas/session.forked" }, @@ -15432,7 +16446,19 @@ "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.context.updated" + "$ref": "#/components/schemas/session.execution.started" + }, + { + "$ref": "#/components/schemas/session.execution.succeeded" + }, + { + "$ref": "#/components/schemas/session.execution.failed" + }, + { + "$ref": "#/components/schemas/session.execution.interrupted" + }, + { + "$ref": "#/components/schemas/session.instructions.updated" }, { "$ref": "#/components/schemas/session.synthetic" @@ -15486,7 +16512,10 @@ "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.retried" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" }, { "$ref": "#/components/schemas/session.compaction.started" @@ -15494,6 +16523,9 @@ { "$ref": "#/components/schemas/session.compaction.ended" }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, { "$ref": "#/components/schemas/session.revert.staged" }, @@ -15536,7 +16568,7 @@ "SessionLogItem": { "anyOf": [ { - "$ref": "#/components/schemas/SessionDurableEvent" + "$ref": "#/components/schemas/Session.Event.Durable" }, { "$ref": "#/components/schemas/EventLog.Synced" @@ -15556,17 +16588,9 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, - "watermark": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "cursor": { "type": "object", "properties": { @@ -15600,65 +16624,6 @@ ], "additionalProperties": false }, - "Model.Api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "package" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "settings" - ], - "additionalProperties": false - } - ] - }, "Model.Capabilities": { "type": "object", "properties": { @@ -15685,6 +16650,33 @@ ], "additionalProperties": false }, + "Model.Variant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "Money.USDPerMillionTokens": { + "type": "number" + }, "Model.Cost": { "type": "object", "properties": { @@ -15708,19 +16700,19 @@ "additionalProperties": false }, "input": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "output": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "write": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" } }, "required": [ @@ -15737,12 +16729,15 @@ ], "additionalProperties": false }, - "ModelV2.Info": { + "Model.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "modelID": { + "type": "string" + }, "providerID": { "type": "string" }, @@ -15752,66 +16747,28 @@ "name": { "type": "string" }, - "api": { - "$ref": "#/components/schemas/Model.Api" + "package": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" }, "capabilities": { "$ref": "#/components/schemas/Model.Capabilities" }, - "request": { - "type": "object", - "properties": { - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "settings", - "headers", - "body" - ], - "additionalProperties": false - }, "variants": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": [ - "id", - "settings", - "headers", - "body" - ], - "additionalProperties": false + "$ref": "#/components/schemas/Model.Variant" } }, "time": { @@ -15866,11 +16823,10 @@ }, "required": [ "id", + "modelID", "providerID", "name", - "api", "capabilities", - "request", "variants", "time", "cost", @@ -15901,63 +16857,6 @@ ], "additionalProperties": false }, - "Provider.AISDK": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "package" - ], - "additionalProperties": false - }, - "Provider.Native": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "settings" - ], - "additionalProperties": false - }, - "Provider.Api": { - "anyOf": [ - { - "$ref": "#/components/schemas/Provider.AISDK" - }, - { - "$ref": "#/components/schemas/Provider.Native" - } - ] - }, "ProviderV2.Info": { "type": "object", "properties": { @@ -15973,18 +16872,26 @@ "disabled": { "type": "boolean" }, - "api": { - "$ref": "#/components/schemas/Provider.Api" + "package": { + "type": "string" }, - "request": { - "$ref": "#/components/schemas/Provider.Request" + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" } }, "required": [ "id", "name", - "api", - "request" + "package" ], "additionalProperties": false }, @@ -16941,6 +17848,80 @@ ], "additionalProperties": false }, + "Mcp.Resource": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uri" + ], + "additionalProperties": false + }, + "Mcp.ResourceTemplate": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uriTemplate": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uriTemplate" + ], + "additionalProperties": false + }, + "Mcp.ResourceCatalog": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Resource" + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.ResourceTemplate" + } + } + }, + "required": [ + "resources", + "templates" + ], + "additionalProperties": false + }, "Project.Vcs": { "type": "string", "enum": [ @@ -18158,7 +19139,7 @@ ], "additionalProperties": false }, - "CommandV2.Info": { + "Command.Info": { "type": "object", "properties": { "name": { @@ -18186,9 +19167,12 @@ ], "additionalProperties": false }, - "SkillV2.Info": { + "Skill.Info": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -18209,6 +19193,7 @@ } }, "required": [ + "id", "name", "location", "content" @@ -18442,7 +19427,7 @@ ], "additionalProperties": false }, - "SnapshotFileDiff": { + "FileDiff.LegacyInfo": { "type": "object", "properties": { "file": { @@ -18506,7 +19491,7 @@ "$ref": "#/components/schemas/PermissionRule" } }, - "Session": { + "SessionV1.Info": { "type": "object", "properties": { "id": { @@ -18560,7 +19545,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -18775,11 +19760,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18805,7 +19788,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18862,11 +19845,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18892,7 +19873,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18911,7 +19892,7 @@ ], "additionalProperties": false }, - "session.deleted": { + "session.deleted1": { "type": "object", "properties": { "id": { @@ -18949,11 +19930,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18979,7 +19958,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -19141,7 +20120,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -19809,11 +20788,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19896,11 +20873,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21366,11 +22341,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21457,11 +22430,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21520,7 +22491,7 @@ ], "additionalProperties": false }, - "session.execution.settled": { + "session.usage.updated": { "type": "object", "properties": { "id": { @@ -21540,7 +22511,7 @@ "type": { "type": "string", "enum": [ - "session.execution.settled" + "session.usage.updated" ] }, "location": { @@ -21557,21 +22528,17 @@ } ] }, - "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "interrupted" - ] + "cost": { + "$ref": "#/components/schemas/Money.USD" }, - "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ "sessionID", - "outcome" + "cost", + "tokens" ], "additionalProperties": false } @@ -21629,8 +22596,13 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" @@ -21639,7 +22611,7 @@ "required": [ "sessionID", "assistantMessageID", - "textID", + "ordinal", "delta" ], "additionalProperties": false @@ -21698,8 +22670,13 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" @@ -21708,7 +22685,7 @@ "required": [ "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "delta" ], "additionalProperties": false @@ -22669,7 +23646,7 @@ "type": "object", "properties": { "info": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -24592,6 +25569,53 @@ ], "additionalProperties": false }, + "mcp.resources.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.resources.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, "permission.asked": { "type": "object", "properties": { @@ -25239,7 +26263,7 @@ "$ref": "#/components/schemas/session.updated" }, { - "$ref": "#/components/schemas/session.deleted" + "$ref": "#/components/schemas/session.deleted1" }, { "$ref": "#/components/schemas/message.updated" @@ -25265,6 +26289,12 @@ { "$ref": "#/components/schemas/session.renamed" }, + { + "$ref": "#/components/schemas/session.usage.updated" + }, + { + "$ref": "#/components/schemas/session.deleted" + }, { "$ref": "#/components/schemas/session.forked" }, @@ -25275,10 +26305,19 @@ "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.execution.settled" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.context.updated" + "$ref": "#/components/schemas/session.execution.succeeded" + }, + { + "$ref": "#/components/schemas/session.execution.failed" + }, + { + "$ref": "#/components/schemas/session.execution.interrupted" + }, + { + "$ref": "#/components/schemas/session.instructions.updated" }, { "$ref": "#/components/schemas/session.synthetic" @@ -25341,7 +26380,10 @@ "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.retried" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" }, { "$ref": "#/components/schemas/session.compaction.started" @@ -25352,6 +26394,9 @@ { "$ref": "#/components/schemas/session.compaction.ended" }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, { "$ref": "#/components/schemas/session.revert.staged" }, @@ -25463,6 +26508,9 @@ { "$ref": "#/components/schemas/mcp.status.changed" }, + { + "$ref": "#/components/schemas/mcp.resources.changed" + }, { "$ref": "#/components/schemas/permission.asked" }, @@ -25493,68 +26541,6 @@ }, "contentMediaType": "application/json" }, - "EventLog.Hint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.hint" - ] - }, - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "type", - "aggregateID", - "seq" - ], - "additionalProperties": false, - "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." - }, - "EventLog.SweepRequired": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.sweep_required" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false, - "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." - }, - "EventLog.Change": { - "anyOf": [ - { - "$ref": "#/components/schemas/EventLog.Hint" - }, - { - "$ref": "#/components/schemas/EventLog.SweepRequired" - } - ] - }, - "EventLog.ChangeStream": { - "type": "string", - "contentSchema": { - "$ref": "#/components/schemas/EventLog.Change" - }, - "contentMediaType": "application/json" - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -25618,6 +26604,182 @@ ], "additionalProperties": false }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, "ShellNotFoundError": { "type": "object", "properties": { @@ -25946,7 +27108,7 @@ }, { "name": "mcp", - "description": "MCP server status routes." + "description": "MCP server and resource routes." }, { "name": "credential" diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 0000000000..01f35998c5 --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/docs", + "private": true, + "scripts": { + "dev": "bun --bun mint dev --no-open --port 3333", + "validate": "bun --bun mint validate", + "broken-links": "bun --bun mint broken-links" + }, + "devDependencies": { + "mint": "4.2.666" + } +} diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index 781bcd5cbe..ce429323d8 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,4 +1,4 @@ -import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { FileDiffInfo, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" @@ -30,7 +30,7 @@ export namespace Share { }), z.object({ type: z.literal("session_diff"), - data: z.custom(), + data: z.custom(), }), z.object({ type: z.literal("model"), diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 8c8ac59f7a..91cb50891d 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -1,4 +1,4 @@ -import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import { FileDiffInfo, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { SessionTurn } from "@opencode-ai/session-ui/session-turn" import { SessionReview } from "@opencode-ai/session-ui/session-review" import { DataProvider } from "@opencode-ai/session-ui/context" @@ -65,7 +65,7 @@ const getData = query(async (shareID) => { shareID: string session: Session[] session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } session_status: { [sessionID: string]: SessionStatus diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index f388e8e253..84c539081e 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -9,13 +9,13 @@ Use it for provider integrations, retries, polling, multi-step flows, and any te ## Install ```sh -bun add effect@4.0.0-beta.74 -bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest +bun add effect@4.0.0-beta.83 +bun add -d @opencode-ai/http-recorder @effect/vitest@4.0.0-beta.83 vitest@^4 ``` The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno. -Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need: +Effect `4.0.0-beta.83` currently contains unresolved symbols in its published declarations. Until those upstream declarations are fixed, TypeScript consumers need: ```json { @@ -51,7 +51,7 @@ describe("getUser", () => { assert.strictEqual(user.id, 1) assert.strictEqual(user.name, "Leanne Graham") - }).pipe(Effect.provide(HttpRecorder.http("users/get-one"))), + }).pipe(Effect.provide(HttpRecorder.layerFetch("users/get-one"))), ) }) ``` @@ -81,49 +81,83 @@ Application code does not need to know whether a response is live or replayed. ## API ```ts -HttpRecorder.http(name, options?) -HttpRecorder.socket(name, options?) +HttpRecorder.layer(name, options?) +HttpRecorder.layerFetch(name, options?) +HttpRecorder.layerSocket(name, options?) +HttpRecorder.layerWebSocketConstructor(name, options?) +HttpRecorder.hasCassetteSync(name, options?) +HttpRecorder.removeCassetteSync(name, options?) ``` -That is the complete public API. `http` provides a fetch-backed recorded `HttpClient`. `socket` decorates a standard Effect `Socket.Socket` supplied beneath it. +That is the complete runtime API. `layer` decorates an application-provided `HttpClient`; `layerFetch` is the convenience layer that supplies Effect's fetch client. `layerWebSocketConstructor` decorates Effect's `Socket.WebSocketConstructor`, recording every dynamically selected URL and protocol. `layerSocket` is the lower-level transport-neutral decorator for an application-provided `Socket.Socket`. + +Use `hasCassetteSync` when registering fixture-gated tests. `removeCassetteSync` explicitly removes one cassette before a focused refresh; removing a missing cassette is a no-op. Both helpers use the same cassette-name validation and default directory as the recorder layers. + +Use `layer` to record through another Effect HTTP transport: + +```ts +import { NodeHttpClient } from "@effect/platform-node" +import { Layer } from "effect" + +const recorder = HttpRecorder.layer("users/get-one").pipe(Layer.provide(NodeHttpClient.layerUndici)) +``` + +The `HttpRecorder` namespace also exposes the configuration types `RecorderOptions`, `SocketRecorderOptions`, `RedactOptions`, `RequestMatcher`, `RequestSnapshot`, and `CassetteMetadata`. ## WebSockets -WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay follows that chronology: server frames are released until the next recorded client frame, then replay waits for the application to send the matching frame before continuing. +Real applications often select WebSocket URLs inside domain services. Effect represents that capability with `Socket.WebSocketConstructor`; production supplies the platform implementation, while tests can decorate it without changing application code. ```ts -import { assert, it } from "@effect/vitest" import { NodeSocket } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { it } from "@effect/vitest" +import { Deferred, Effect, Layer } from "effect" import { Socket } from "effect/unstable/socket" import { HttpRecorder } from "@opencode-ai/http-recorder" -const echo = Effect.gen(function* () { - const socket = yield* Socket.Socket +const roundTrip = Effect.fn("Echo.roundTrip")(function* (url: string, message: string) { + const socket = yield* Socket.makeWebSocket(url, { closeCodeIsError: () => false }) const write = yield* socket.writer + const echoed = yield* Deferred.make() yield* socket.runString( - (message) => - Effect.gen(function* () { - assert.strictEqual(message, "hello") - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, + (response) => { + return Deferred.succeed(echoed, response).pipe( + Effect.andThen(write(new Socket.CloseEvent(1000, "done"))), + Effect.orDie, + ) + }, + { onOpen: write(message).pipe(Effect.orDie) }, ) + + return yield* Deferred.await(echoed) }) -const recordedSocket = HttpRecorder.socket("echo/hello").pipe( - Layer.provide( - NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", { - closeCodeIsError: (code) => code !== 1000, - }), +it.effect("round trips a message", () => + roundTrip("wss://ws.postman-echo.com/raw", "hello").pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerWebSocketConstructor("echo/round-trip").pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), + ), + ), ), ) - -it.effect("exchanges WebSocket frames", () => echo.pipe(Effect.provide(recordedSocket))) ``` -The application owns the WebSocket URL and protocols through normal Effect layer wiring. The recorder wraps that socket without duplicating its URL in recorder configuration. Provide separate socket layers for separate endpoints or concurrent connections. +The production application supplies only `NodeSocket.layerWebSocketConstructor`. The recorder appears in test wiring and observes each call to `Socket.makeWebSocket`, including URLs selected at runtime. + +`socket.runString` owns the receive loop and finishes when the connection closes or fails. Its optional `onOpen` effect is the safe place to send protocols whose client speaks first. The writer is scoped because sending is valid only while a connection run is active. + +WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay releases recorded server frames until it reaches a client frame, waits for the application to write the matching frame, then continues. This preserves causal ordering without reproducing network timing. + +Client text frames containing JSON compare canonically, so object-key order does not matter. Changed fields, extra fields, non-JSON text, and binary frames must match exactly after redaction. There is intentionally no custom WebSocket matcher in this beta. + +Incoming frame handlers start in recorded order and may run concurrently, matching Effect's socket abstraction. Replay waits for all handlers before the socket run completes, but handler completion order is not guaranteed. Use Effect synchronization such as `Queue`, `Ref`, or `Deferred` instead of unsynchronized mutable state. + +A constructor cassette records the URL, requested protocols, frames, and terminal close for each connection. Replay validates the URL and protocols before opening the simulated socket. Closing before every recorded frame is consumed fails the test. + +Use `layerSocket` when a protocol layer already consumes one application-provided `Socket.Socket`, including non-WebSocket transports. Because that lower-level abstraction has no URL or protocols, its cassettes use the cassette name and connection order as identity. Text frames use the same JSON-field and body redaction as HTTP bodies. Binary frames are stored losslessly as base64. Client and server frame kinds must match during replay. @@ -143,7 +177,7 @@ There is intentionally no public overwrite mode. Deletion makes the set of recor Secure defaults remove most headers and redact common credentials in headers, URLs, and JSON bodies. Extend those defaults at layer construction: ```ts -HttpRecorder.http("anthropic/messages", { +HttpRecorder.layerFetch("anthropic/messages", { redact: { headers: ["x-project-token"], allowRequestHeaders: ["anthropic-version"], @@ -171,16 +205,16 @@ Redaction is defense in depth, not a substitute for review. Inspect cassette dif ## Matching And Ordering -A cassette contains an ordered sequence of interactions. The first runtime request is checked against the first recorded request, the second against the second, and so on. +A runtime request atomically claims the first unused recorded interaction that matches it. Distinct requests may replay in any order or concurrently. -This strict ordering correctly models repeated identical requests whose responses change, including retries, polling, and cache tests. JSON object keys are canonicalized before matching. +Repeated identical requests consume their matching responses in cassette order, which models retries, polling, and cache tests deterministically. A mismatch consumes nothing, and JSON object keys are canonicalized before matching. -Concurrent requests are recorded in request-start order even when their responses complete out of order. +Concurrent requests are recorded in request-start order even when their responses complete out of order. Each recorded interaction can be claimed only once, and leaving interactions unused fails when the recorder layer closes. Supply a custom equivalence rule when a request contains intentionally volatile data: ```ts -HttpRecorder.http("events/create", { +HttpRecorder.layerFetch("events/create", { match: (incoming, recorded) => incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname, }) @@ -191,14 +225,18 @@ HttpRecorder.http("events/create", { ```ts interface RecorderOptions { readonly directory?: string - readonly metadata?: Record + readonly metadata?: Readonly> readonly redact?: RedactOptions readonly match?: RequestMatcher } + +type SocketRecorderOptions = Omit ``` `directory` defaults to `/test/fixtures/recordings`. +See [`examples/`](./examples) for complete HTTP and WebSocket examples. + ## Cassettes Cassettes are readable JSON files intended to be committed with your tests. HTTP interactions are stored in request order. WebSocket cassettes preserve the observed order of client and server frames. Text stays readable; binary bodies and frames are stored losslessly as base64. @@ -207,7 +245,8 @@ Cassettes are readable JSON files intended to be committed with your tests. HTTP - Responses are buffered while recording and replaying, so this beta is not suitable for tests that assert streaming timing, cancellation, or backpressure. - WebSocket replay preserves frame chronology and content, not real network timing or backpressure. -- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, or transport failures. Failed and interrupted live runs are not recorded. +- Constructor-level WebSocket cassettes reproduce terminal close codes and reasons, but not selected subprotocols, handshake headers, transport timing, or transport failures. Lower-level `layerSocket` cassettes contain frames only. +- Failed and interrupted live WebSocket connections are not recorded. - WebSocket transcripts are retained in memory until the connection finishes; avoid using this beta for unbounded sessions. - The package currently requires the exact Effect beta listed above. - Cassette format version `1` has no migration tooling yet. diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 0d4f1175ad..b3166190f6 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "version": "1.17.14", "name": "@opencode-ai/http-recorder", - "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", + "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", "license": "MIT", "repository": { @@ -28,14 +28,14 @@ }, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "typecheck": "tsgo --noEmit", + "typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit", "build": "bun ./script/build.ts", "verify:package": "bun ./script/verify-package.ts" }, "exports": { - ".": "./src/index.ts", - "./internal": "./src/internal.ts" + ".": "./src/index.ts" }, + "sideEffects": false, "files": [ "dist", "README.md", @@ -47,14 +47,14 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@effect/platform-node": "catalog:", "effect": "catalog:", "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.83" + "effect": "catalog:" } } diff --git a/packages/http-recorder/script/build.ts b/packages/http-recorder/script/build.ts index f7d839236e..9d9de65e62 100644 --- a/packages/http-recorder/script/build.ts +++ b/packages/http-recorder/script/build.ts @@ -14,7 +14,13 @@ const build = await Bun.build({ }) if (!build.success) throw new AggregateError(build.logs, "Failed to build @opencode-ai/http-recorder") -const publicFiles = new Set(["index.js", "index.d.ts", "effect.d.ts", "socket.d.ts", "types.d.ts"]) await Promise.all( - (await readdir("dist")).filter((file) => !publicFiles.has(file)).map((file) => rm(`dist/${file}`, { force: true })), + (await readdir("dist", { recursive: true })) + .filter((file) => file.endsWith(".d.ts") && file !== "index.d.ts" && file !== "api.d.ts") + .map((file) => rm(`dist/${file}`)), ) + +for (const file of ["dist/index.d.ts", "dist/api.d.ts"]) { + if ((await Bun.file(file).text()).includes(["import", "("].join(""))) + throw new Error(`${file} contains dynamic import syntax`) +} diff --git a/packages/http-recorder/script/pack.ts b/packages/http-recorder/script/pack.ts index 4dad7a4416..c40d83c849 100644 --- a/packages/http-recorder/script/pack.ts +++ b/packages/http-recorder/script/pack.ts @@ -15,10 +15,6 @@ export const pack = async () => { } for (const [key, value] of Object.entries(pkg.exports)) { - if (key === "./internal") { - delete pkg.exports[key] - continue - } if (typeof value !== "string") continue const file = value.replace("./src/", "./dist/").replace(/\.ts$/, "") pkg.exports[key] = { import: `${file}.js`, types: `${file}.d.ts` } @@ -32,4 +28,14 @@ export const pack = async () => { } } +export const withPackedArchive = async (use: (archive: string) => Promise) => { + const archive = await pack() + try { + return await use(archive) + } finally { + const file = Bun.file(archive) + if (await file.exists()) await file.delete() + } +} + if (import.meta.main) await pack() diff --git a/packages/http-recorder/script/verify-package.ts b/packages/http-recorder/script/verify-package.ts index 272394c944..04f1f2bca5 100644 --- a/packages/http-recorder/script/verify-package.ts +++ b/packages/http-recorder/script/verify-package.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" -import { pack } from "./pack.js" +import { withPackedArchive } from "./pack.js" const run = async (command: ReadonlyArray, cwd: string) => { const process = Bun.spawn(command, { cwd, env: globalThis.process.env, stdout: "inherit", stderr: "inherit" }) @@ -10,6 +10,11 @@ const run = async (command: ReadonlyArray, cwd: string) => { if (exitCode !== 0) throw new Error(`${command.join(" ")} exited with code ${exitCode}`) } +const reject = async (command: ReadonlyArray, cwd: string) => { + const process = Bun.spawn([...command], { cwd, env: globalThis.process.env, stdout: "ignore", stderr: "ignore" }) + if ((await process.exited) === 0) throw new Error(`${command.join(" ")} unexpectedly succeeded`) +} + export const verifyPackage = async (archive: string) => { const directory = await mkdtemp(path.join(tmpdir(), "http-recorder-consumer-")) try { @@ -25,11 +30,40 @@ import { Layer } from "effect" import { HttpClient } from "effect/unstable/http" import { Socket } from "effect/unstable/socket" -const options: HttpRecorder.RecorderOptions = { redact: { jsonFields: ["access_token"] } } -HttpRecorder.http("consumer", options) satisfies Layer.Layer -HttpRecorder.socket("consumer/socket", options).pipe( +const options: HttpRecorder.RecorderOptions = { match: () => true, redact: { jsonFields: ["access_token"] } } +const socketOptions: HttpRecorder.SocketRecorderOptions = { redact: { jsonFields: ["access_token"] } } +HttpRecorder.layer("consumer", options) satisfies Layer.Layer +HttpRecorder.layerFetch("consumer", options) satisfies Layer.Layer +HttpRecorder.hasCassetteSync("consumer", { directory: "recordings" }) satisfies boolean +HttpRecorder.removeCassetteSync("consumer", { directory: "recordings" }) +HttpRecorder.layerSocket("consumer/socket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocket("wss://example.test")), ) satisfies Layer.Layer +HttpRecorder.layerWebSocketConstructor("consumer/websocket", socketOptions).pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), +) satisfies Layer.Layer +// @ts-expect-error HTTP request matching does not apply to WebSocket frames. +HttpRecorder.layerSocket("consumer/socket", { match: () => true }) +`, + ) + await writeFile( + path.join(directory, "exports.mjs"), + `import { HttpRecorder } from "@opencode-ai/http-recorder" + +const root = Object.keys(await import("@opencode-ai/http-recorder")).sort() +if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) { + throw new Error(\`Unexpected root exports: \${root}\`) +} + +const namespace = Object.keys(HttpRecorder).sort() +if (JSON.stringify(namespace) !== JSON.stringify(["hasCassetteSync", "layer", "layerFetch", "layerSocket", "layerWebSocketConstructor", "removeCassetteSync"])) { + throw new Error(\`Unexpected HttpRecorder exports: \${namespace}\`) +} +`, + ) + await writeFile( + path.join(directory, "deep-import.mjs"), + `import "@opencode-ai/http-recorder/internal" `, ) await writeFile( @@ -41,7 +75,7 @@ HttpRecorder.socket("consumer/socket", options).pipe( moduleResolution: "NodeNext", strict: true, noEmit: true, - // Required by effect@4.0.0-beta.74: its schema.d.ts references an undeclared SchemaErrorTypeId. + // Required by effect@4.0.0-beta.83: its declarations currently contain unresolved internal symbols. skipLibCheck: true, lib: ["ES2022", "DOM", "ESNext.Disposable"], }, @@ -49,27 +83,28 @@ HttpRecorder.socket("consumer/socket", options).pipe( }), ) - await run(["npm", "install", archive, "typescript@5.8.2"], directory) await run( [ - "node", - "--input-type=module", - "-e", - 'import("@opencode-ai/http-recorder").then((module) => { const root = Object.keys(module).sort(); const namespace = Object.keys(module.HttpRecorder).sort(); if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) throw new Error(`Unexpected root exports: ${root}`); if (JSON.stringify(namespace) !== JSON.stringify(["http", "socket"])) throw new Error(`Unexpected namespace exports: ${namespace}`) })', + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + archive, + "typescript@5.8.2", + "effect@4.0.0-beta.83", + "@effect/platform-node@4.0.0-beta.83", ], directory, ) + await run(["node", path.join(directory, "exports.mjs")], directory) + await run(["bun", path.join(directory, "exports.mjs")], directory) + await reject(["node", path.join(directory, "deep-import.mjs")], directory) await run([path.join(directory, "node_modules", ".bin", "tsc"), "--noEmit"], directory) } finally { await rm(directory, { recursive: true, force: true }) } } -if (import.meta.main) { - const archive = await pack() - try { - await verifyPackage(archive) - } finally { - await Bun.file(archive).delete() - } -} +if (import.meta.main) await withPackedArchive(verifyPackage) diff --git a/packages/http-recorder/src/api.ts b/packages/http-recorder/src/api.ts new file mode 100644 index 0000000000..47d652029b --- /dev/null +++ b/packages/http-recorder/src/api.ts @@ -0,0 +1,46 @@ +/** JSON-compatible cassette metadata value. */ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +/** Additional JSON metadata stored with a cassette. */ +export type CassetteMetadata = Readonly> + +/** The normalized HTTP request representation used for matching. */ +export interface RequestSnapshot { + readonly method: string + readonly url: string + readonly headers: Record + readonly body: string +} + +/** Returns whether an incoming HTTP request matches a recorded request. */ +export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean + +/** Additive redaction and header-preservation policy. */ +export interface RedactOptions { + readonly headers?: ReadonlyArray + readonly allowRequestHeaders?: ReadonlyArray + readonly allowResponseHeaders?: ReadonlyArray + readonly queryParameters?: ReadonlyArray + readonly jsonFields?: ReadonlyArray + readonly url?: (url: string) => string + readonly body?: (body: string) => string +} + +/** Options shared by HTTP recorder layers. */ +export interface RecorderOptions { + readonly directory?: string + readonly metadata?: CassetteMetadata + readonly redact?: RedactOptions + readonly match?: RequestMatcher +} + +/** Recorder configuration for Effect socket and WebSocket layers. */ +export type SocketRecorderOptions = Omit + +export * as Api from "./api.js" diff --git a/packages/http-recorder/src/cassette/model.ts b/packages/http-recorder/src/cassette/model.ts new file mode 100644 index 0000000000..8667505da8 --- /dev/null +++ b/packages/http-recorder/src/cassette/model.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import type { CassetteMetadata, JsonValue } from "../api.js" +import { HttpInteractionSchema } from "../http/model.js" +import { WebSocketInteractionSchema } from "../websocket/model.js" + +export type { CassetteMetadata, JsonValue } from "../api.js" + +const JsonValueSchema = Schema.suspend( + (): Schema.Codec => + Schema.Union([ + Schema.Null, + Schema.Boolean, + Schema.Number, + Schema.String, + Schema.Array(JsonValueSchema), + Schema.Record(Schema.String, JsonValueSchema), + ]), +) + +export const CassetteMetadataSchema = Schema.Record(Schema.String, JsonValueSchema) + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +export const isHttpInteraction = InteractionSchema.guards.http +export const isWebSocketInteraction = InteractionSchema.guards.websocket +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) + +export const CassetteSchema = Schema.Struct({ + version: Schema.Literal(1), + metadata: Schema.optional(CassetteMetadataSchema), + interactions: Schema.Array(InteractionSchema), +}) +export type Cassette = Schema.Schema.Type + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) + +export * as CassetteModel from "./model.js" diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette/store.ts similarity index 81% rename from packages/http-recorder/src/cassette.ts rename to packages/http-recorder/src/cassette/store.ts index 5138bb12df..2079632bea 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette/store.ts @@ -1,8 +1,8 @@ import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js" -import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js" +import { existsSync, rmSync } from "node:fs" +import path from "node:path" +import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js" +import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js" const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") @@ -14,6 +14,15 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass()("InvalidCassetteError", { + cassetteName: Schema.String, + description: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" is invalid: ${this.description}` + } +} + export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { cassetteName: Schema.String, findings: Schema.Array(SecretFindingSchema), @@ -26,7 +35,9 @@ export class UnsafeCassetteError extends Schema.TaggedErrorClass Effect.Effect, CassetteNotFoundError> + readonly read: ( + name: string, + ) => Effect.Effect, CassetteNotFoundError | InvalidCassetteError> readonly append: ( name: string, interaction: Interaction, @@ -50,7 +61,10 @@ const cassettePath = (directory: string, name: string) => { } export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => - fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + +export const removeCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + rmSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name), { force: true }) const buildCassette = ( name: string, @@ -58,14 +72,16 @@ const buildCassette = ( metadata: CassetteMetadata | undefined, ): Cassette => ({ version: 1, - metadata: { name, recordedAt: new Date().toISOString(), ...metadata }, + metadata: { ...metadata, name, recordedAt: new Date().toISOString() }, interactions, }) - const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema)) - +const invalidCassette = (name: string, error: unknown) => + new InvalidCassetteError({ + cassetteName: name, + description: error instanceof Error ? error.message : String(error), + }) const failIfUnsafe = (name: string, findings: ReadonlyArray) => findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })) @@ -79,9 +95,7 @@ export const fileSystem = ( const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const recorded = new Map() const appendLock = yield* Semaphore.make(1) - const pathFor = (name: string) => cassettePath(directory, name) - const walk = (current: string): Effect.Effect> => Effect.gen(function* () { const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -98,8 +112,17 @@ export const fileSystem = ( return Service.of({ read: (name) => fs.readFileString(pathFor(name)).pipe( - Effect.map((raw) => parseCassette(raw).interactions), - Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + Effect.mapError((error) => + error.reason._tag === "NotFound" + ? new CassetteNotFoundError({ cassetteName: name }) + : invalidCassette(name, error), + ), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseCassette(raw).interactions, + catch: (error) => invalidCassette(name, error), + }), + ), ), append: (name, interaction, metadata) => appendLock.withPermit( @@ -151,7 +174,6 @@ export const memory = (initial: Record> = {}) ) const accumulatedFindings = new Map() const appendLock = Semaphore.makeUnsafe(1) - return Service.of({ read: (name) => stored.has(name) @@ -162,7 +184,7 @@ export const memory = (initial: Record> = {}) Effect.suspend(() => { const interactions = [...(stored.get(name) ?? []), interaction] const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)] - const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings + const allFindings = metadata ? [...findings, ...secretFindings({ ...metadata, name })] : findings return failIfUnsafe(name, allFindings).pipe( Effect.tap(() => Effect.sync(() => { diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts deleted file mode 100644 index 583dee8ca1..0000000000 --- a/packages/http-recorder/src/effect.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import * as Layer from "effect/Layer" -import { FetchHttpClient } from "effect/unstable/http" -import type * as HttpClient from "effect/unstable/http/HttpClient" -import * as CassetteService from "./cassette.js" -import { recordingLayer } from "./internal-effect.js" -import { make } from "./redactor.js" -import type { RecorderOptions } from "./types.js" - -/** - * Provides a fetch-backed `HttpClient` with cassette recording and replay. - * - * Locally, a missing cassette is recorded from the real service. Existing - * cassettes are replayed, and `CI=true` makes a missing cassette fail. - */ -export const http = (name: string, options: RecorderOptions = {}): Layer.Layer => - recordingLayer(name, { - metadata: options.metadata, - redactor: make(options.redact), - match: options.match, - }).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/http/matching.ts similarity index 54% rename from packages/http-recorder/src/matching.ts rename to packages/http-recorder/src/http/matching.ts index 731aa8b57a..fcd446edda 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/http/matching.ts @@ -1,64 +1,31 @@ -import { Option, Schema } from "effect" -import { REDACTED, secretFindings } from "./redaction.js" -import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js" +import { HashSet, Option } from "effect" +import type { RequestMatcher, RequestSnapshot } from "../api.js" +import { canonicalizeJson, decodeJson, isJsonRecord, jsonBody, safeText } from "../replay/comparison.js" +import type { HttpInteraction } from "./model.js" -const JsonValue = Schema.fromJsonString(Schema.Unknown) -export const decodeJson = Schema.decodeUnknownOption(JsonValue) - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value) - -export const canonicalizeJson = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(canonicalizeJson) - if (isRecord(value)) { - return Object.fromEntries( - Object.keys(value) - .toSorted() - .map((key) => [key, canonicalizeJson(value[key])]), - ) - } - return value -} - -export type { RequestMatcher } from "./types.js" +export type { RequestMatcher } from "../api.js" export const canonicalSnapshot = (snapshot: RequestSnapshot): string => JSON.stringify({ method: snapshot.method, url: snapshot.url, headers: canonicalizeJson(snapshot.headers), - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: canonicalizeJson, - }), + body: Option.match(decodeJson(snapshot.body), { onNone: () => snapshot.body, onSome: canonicalizeJson }), }) - export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) -export const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = JSON.stringify(value) - if (!text) return typeof value - return text.length > 300 ? `${text.slice(0, 300)}...` : text -} - -const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) - const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { if (Object.is(expected, received)) return [] - if (isRecord(expected) && isRecord(received)) { + if (isJsonRecord(expected) && isJsonRecord(received)) return [...new Set([...Object.keys(expected), ...Object.keys(received)])] .toSorted() .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) .slice(0, limit) - } - if (Array.isArray(expected) && Array.isArray(received)) { + if (Array.isArray(expected) && Array.isArray(received)) return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) .slice(0, limit) - } return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] } @@ -72,12 +39,9 @@ const headerDiffs = (expected: Record, received: Record => { const lines: string[] = [] - if (expected.method !== received.method) { + if (expected.method !== received.method) lines.push("method:", ` expected ${expected.method}, received ${received.method}`) - } - if (expected.url !== received.url) { - lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) - } + if (expected.url !== received.url) lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) const headers = headerDiffs(expected.headers, received.headers) if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) const expectedBody = jsonBody(expected.body) @@ -92,15 +56,22 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot return lines } -export const selectSequential = ( +export const selectFirstMatching = ( interactions: ReadonlyArray, incoming: RequestSnapshot, match: RequestMatcher, - index: number, -): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { - const interaction = interactions[index] - if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } - if (!match(incoming, interaction.request)) - return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } - return { interaction, detail: "" } + used: HashSet.HashSet, +): { readonly _tag: "Matched"; readonly index: number } | { readonly _tag: "Unmatched"; readonly detail: string } => { + let firstUnused: HttpInteraction | undefined + for (let index = 0; index < interactions.length; index++) { + if (HashSet.has(used, index)) continue + const interaction = interactions[index] + firstUnused ??= interaction + if (match(incoming, interaction.request)) return { _tag: "Matched", index } + } + if (firstUnused === undefined) + return { _tag: "Unmatched", detail: `all ${interactions.length} recorded interactions have already been consumed` } + return { _tag: "Unmatched", detail: requestDiff(firstUnused.request, incoming).join("\n") } } + +export * as HttpMatching from "./matching.js" diff --git a/packages/http-recorder/src/http/model.ts b/packages/http-recorder/src/http/model.ts new file mode 100644 index 0000000000..861d502f88 --- /dev/null +++ b/packages/http-recorder/src/http/model.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect" +import type { RequestSnapshot } from "../api.js" + +export const RequestSnapshotSchema = Schema.Struct({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}) + +export type { RequestSnapshot } from "../api.js" + +export const ResponseSnapshotSchema = Schema.Struct({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, + bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), +}) + +export interface ResponseSnapshot extends Schema.Schema.Type {} + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) + +export interface HttpInteraction extends Schema.Schema.Type {} + +export * as HttpModel from "./model.js" diff --git a/packages/http-recorder/src/internal-effect.ts b/packages/http-recorder/src/http/recorder.ts similarity index 67% rename from packages/http-recorder/src/internal-effect.ts rename to packages/http-recorder/src/http/recorder.ts index 6b886311a9..568b101743 100644 --- a/packages/http-recorder/src/internal-effect.ts +++ b/packages/http-recorder/src/http/recorder.ts @@ -1,27 +1,22 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Layer, Option, Ref } from "effect" +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Layer, Ref } from "effect" import { FetchHttpClient, - Headers, - HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, - UrlParams, } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { defaultMatcher, selectSequential } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { redactUrl } from "./redaction.js" -import { httpInteractions } from "./schema.js" -import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js" +import { fileSystem, Service } from "../cassette/store.js" +import type { RecorderOptions } from "../options.js" +import { make, redactUrl, type Redactor } from "../redaction/redactor.js" +import { makeReplayPoolState, resolveAutoMode } from "../replay/state.js" +import { httpInteractions, type CassetteMetadata } from "../cassette/model.js" +import { defaultMatcher, selectFirstMatching, type RequestMatcher } from "./matching.js" +import type { HttpInteraction, ResponseSnapshot } from "./model.js" export { defaultMatcher } - export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" - export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string @@ -40,7 +35,6 @@ const TEXT_CONTENT_TYPES = new Set([ "application/yaml", "image/svg+xml", ]) - const isTextContentType = (contentType: string | undefined) => { const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() if (!mediaType) return false @@ -51,7 +45,6 @@ const isTextContentType = (contentType: string | undefined) => { TEXT_CONTENT_TYPES.has(mediaType) ) } - const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => response.arrayBuffer.pipe( Effect.map((bytes) => @@ -60,10 +53,8 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const }, ), ) - const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body - const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) => HttpClientResponse.fromWeb( request, @@ -75,35 +66,28 @@ const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snap ), ) -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) - -const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => +export const redactedErrorRequest = ( + request: HttpClientRequest.HttpClientRequest, + redactedUrl = redactUrl(request.url), +) => HttpClientRequest.make(request.method)(redactedUrl) +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string, redactedUrl?: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request, redactedUrl), description }), }) export const recordingLayer = ( name: string, options: Omit = {}, -): Layer.Layer => +): Layer.Layer => Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient - const cassetteService = yield* CassetteService.Service + const cassette = yield* Service const redactor = options.redactor ?? make() const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" - const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested - + const mode = requested === "auto" ? yield* resolveAutoMode(cassette, name) : requested const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) @@ -114,9 +98,7 @@ export const recordingLayer = ( body: yield* Effect.promise(() => web.text()), }) }) - if (mode === "passthrough") return upstream - if (mode === "record") { const initial = yield* Deferred.make() yield* Deferred.succeed(initial, undefined) @@ -127,6 +109,7 @@ export const recordingLayer = ( const previous = yield* Ref.modify(tail, (current) => [current, completed]) return yield* Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const response = yield* upstream.execute(request) const captured = yield* captureResponseBody(response, response.headers["content-type"]) const responseSnapshot: ResponseSnapshot = { @@ -140,39 +123,32 @@ export const recordingLayer = ( response: redactor.response(responseSnapshot), } yield* Deferred.await(previous) - yield* cassetteService + yield* cassette .append(name, interaction, options.metadata) - .pipe( - Effect.catchTag("UnsafeCassetteError", (error) => - Effect.fail(transportError(request, error.message)), - ), - ) + .pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(requestError(error.message)))) return responseFromSnapshot(request, responseSnapshot) }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))) }), ) } - - const replay = yield* makeReplayState(cassetteService, name, httpInteractions) + const replay = yield* makeReplayPoolState(cassette, name, httpInteractions) return HttpClient.make((request) => Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const claimed = yield* replay - .claim((interaction, index, interactions) => { - const result = selectSequential(interactions, incoming, match, index) - if (result.interaction) return Effect.void + .claim((interactions, used) => { + const result = selectFirstMatching(interactions, incoming, match, used) + if (result._tag === "Matched") return Effect.succeed(result.index) return Effect.fail( - transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + requestError(`Fixture "${name}" does not match the current request: ${result.detail}.`), ) }) .pipe( Effect.mapError((error) => error._tag === "CassetteNotFoundError" - ? transportError( - request, - `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, - ) - : error, + ? requestError(`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`) + : requestError(error.message), ), ) return responseFromSnapshot(request, claimed.interaction.response) @@ -183,7 +159,18 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) + +export const layer = ( + name: string, + options: RecorderOptions = {}, +): Layer.Layer => + recordingLayer(name, { metadata: options.metadata, redactor: make(options.redact), match: options.match }).pipe( + Layer.provide(fileSystem({ directory: options.directory })), + Layer.provide(NodeFileSystem.layer), + ) +export const layerFetch = (name: string, options: RecorderOptions = {}): Layer.Layer => + layer(name, options).pipe(Layer.provide(FetchHttpClient.layer)) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index eac4b75b4b..2dcba03e6f 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,18 +1,43 @@ -import { http } from "./effect.js" -import { socket } from "./socket.js" +import { Layer } from "effect" +import { HttpClient } from "effect/unstable/http" +import { Socket } from "effect/unstable/socket" +import { Api } from "./api.js" +import { hasCassetteSync, removeCassetteSync } from "./cassette/store.js" +import { layer, layerFetch } from "./http/recorder.js" +import { layerSocket, layerWebSocketConstructor } from "./websocket/recorder.js" /** HTTP and WebSocket cassette recording. */ -export const HttpRecorder = { http, socket } as const +export const HttpRecorder: { + readonly layer: ( + name: string, + options?: Api.RecorderOptions, + ) => Layer.Layer + readonly layerFetch: (name: string, options?: Api.RecorderOptions) => Layer.Layer + readonly layerSocket: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly layerWebSocketConstructor: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly hasCassetteSync: (name: string, options?: { readonly directory?: string }) => boolean + readonly removeCassetteSync: (name: string, options?: { readonly directory?: string }) => void +} = { hasCassetteSync, layer, layerFetch, layerSocket, layerWebSocketConstructor, removeCassetteSync } export namespace HttpRecorder { /** Additional JSON metadata stored with a cassette. */ - export type CassetteMetadata = import("./types.js").CassetteMetadata + export type JsonValue = Api.JsonValue + /** Additional JSON metadata stored with a cassette. */ + export type CassetteMetadata = Api.CassetteMetadata /** Recorder configuration. */ - export type RecorderOptions = import("./types.js").RecorderOptions + export type RecorderOptions = Api.RecorderOptions /** Additive redaction and header-preservation policy. */ - export type RedactOptions = import("./types.js").RedactOptions + export type RedactOptions = Api.RedactOptions /** Returns whether an incoming HTTP request matches a recorded request. */ - export type RequestMatcher = import("./types.js").RequestMatcher + export type RequestMatcher = Api.RequestMatcher /** The normalized HTTP request representation used for matching. */ - export type RequestSnapshot = import("./types.js").RequestSnapshot + export type RequestSnapshot = Api.RequestSnapshot + /** Recorder configuration for Effect socket and WebSocket layers. */ + export type SocketRecorderOptions = Api.SocketRecorderOptions } diff --git a/packages/http-recorder/src/internal.ts b/packages/http-recorder/src/internal.ts deleted file mode 100644 index 7faecf0db0..0000000000 --- a/packages/http-recorder/src/internal.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js" -export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js" -export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js" -export { socketLayer } from "./socket.js" -export { - makeWebSocketExecutor, - type WebSocketConnection, - type WebSocketExecutor, - type WebSocketRecordReplayOptions, - type WebSocketRequest, -} from "./websocket.js" -export * as Cassette from "./cassette.js" -export * as Redactor from "./redactor.js" - -export * as HttpRecorderInternal from "./internal.js" diff --git a/packages/http-recorder/src/options.ts b/packages/http-recorder/src/options.ts new file mode 100644 index 0000000000..df62d6a247 --- /dev/null +++ b/packages/http-recorder/src/options.ts @@ -0,0 +1 @@ +export type { RecorderOptions, RedactOptions, SocketRecorderOptions } from "./api.js" diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts deleted file mode 100644 index c58afde463..0000000000 --- a/packages/http-recorder/src/recorder.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, Scope, SynchronizedRef } from "effect" -import type * as CassetteService from "./cassette.js" -import type { CassetteNotFoundError } from "./cassette.js" -import type { Interaction } from "./schema.js" - -const isCI = () => { - const value = process.env.CI - return value !== undefined && value !== "" && value !== "false" && value !== "0" -} - -export const resolveAutoMode = ( - cassette: CassetteService.Interface, - name: string, -): Effect.Effect<"record" | "replay" | "passthrough"> => - Effect.gen(function* () { - if (isCI()) return "replay" - return (yield* cassette.exists(name)) ? "replay" : "record" - }) - -export interface ReplayState { - readonly claim: ( - validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E> -} - -export const makeReplayState = ( - cassette: CassetteService.Interface, - name: string, - project: (interactions: ReadonlyArray) => ReadonlyArray, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) - const position = yield* SynchronizedRef.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used === 0) return yield* Effect.void - const interactions = yield* load.pipe(Effect.orDie) - if (used < interactions.length) - return yield* Effect.die( - new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), - ) - return yield* Effect.void - }), - ) - - return { - claim: (validate) => - Effect.flatMap(load, (interactions) => - SynchronizedRef.modifyEffect(position, (index) => - Effect.gen(function* () { - const interaction = interactions[index] - yield* validate(interaction, index, interactions) - if (interaction === undefined) - return yield* Effect.die("Replay validation accepted a missing interaction") - return [{ interaction, index }, index + 1] as const - }), - ), - ), - } - }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts deleted file mode 100644 index b84996adec..0000000000 --- a/packages/http-recorder/src/redaction.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Schema } from "effect" - -export const REDACTED = "[REDACTED]" - -const DEFAULT_REDACT_HEADERS = [ - "authorization", - "cookie", - "proxy-authorization", - "set-cookie", - "x-api-key", - "x-amz-security-token", - "x-goog-api-key", -] - -const DEFAULT_REDACT_QUERY = [ - "access_token", - "api-key", - "api_key", - "apikey", - "code", - "key", - "signature", - "sig", - "token", - "x-amz-credential", - "x-amz-security-token", - "x-amz-signature", -] - -const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ - { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, - { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, - { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, - { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, - { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, - { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, - { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, -] - -const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i -const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) - -const envSecrets = () => - Object.entries(process.env).flatMap(([name, value]) => { - if (!value) return [] - if (!ENV_SECRET_NAMES.test(name)) return [] - if (value.length < 12) return [] - if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] - return [{ name, value }] - }) - -const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) - -const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { - if (typeof value === "string") return [{ path: base, value }] - if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) - if (value && typeof value === "object") { - return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) - } - return [] -} - -const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => - new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) - -export type UrlRedactor = (url: string) => string - -export const redactUrl = ( - raw: string, - query: ReadonlyArray = DEFAULT_REDACT_QUERY, - urlRedactor?: UrlRedactor, -) => { - if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw - const url = new URL(raw) - if (url.username) url.username = REDACTED - if (url.password) url.password = REDACTED - const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) - for (const key of url.searchParams.keys()) { - if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) - } - return urlRedactor?.(url.toString()) ?? url.toString() -} - -export const redactHeaders = ( - headers: Record, - allow: ReadonlyArray, - redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, -) => { - const allowed = new Set(allow.map((name) => name.toLowerCase())) - const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) - return Object.fromEntries( - Object.entries(headers) - .map(([name, value]) => [name.toLowerCase(), value] as const) - .filter(([name]) => allowed.has(name)) - .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) - .toSorted(([a], [b]) => a.localeCompare(b)), - ) -} - -export const SecretFindingSchema = Schema.Struct({ - path: Schema.String, - reason: Schema.String, -}) -export type SecretFinding = Schema.Schema.Type - -export const secretFindings = (value: unknown): ReadonlyArray => { - const environment = envSecrets() - return stringEntries(value).flatMap((entry) => [ - ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ - path: entry.path, - reason: item.label, - })), - ...environment - .filter((item) => entry.value.includes(item.value)) - .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), - ]) -} diff --git a/packages/http-recorder/src/redaction/redactor.ts b/packages/http-recorder/src/redaction/redactor.ts new file mode 100644 index 0000000000..d921f55d73 --- /dev/null +++ b/packages/http-recorder/src/redaction/redactor.ts @@ -0,0 +1,173 @@ +import { Option, Schema } from "effect" +import type { RequestSnapshot, ResponseSnapshot } from "../http/model.js" +import type { RedactOptions } from "../options.js" + +export type { RedactOptions } from "../options.js" +export const REDACTED = "[REDACTED]" + +const DEFAULT_REDACT_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-amz-security-token", + "x-goog-api-key", +] +const DEFAULT_REDACT_QUERY = [ + "access_token", + "api-key", + "api_key", + "apikey", + "code", + "key", + "signature", + "sig", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", +] +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export const redactUrl = ( + raw: string, + query: ReadonlyArray = DEFAULT_REDACT_QUERY, + transform?: (url: string) => string, +) => { + if (!URL.canParse(raw)) return transform?.(raw) ?? raw + const url = new URL(raw) + if (url.username) url.username = REDACTED + if (url.password) url.password = REDACTED + const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + for (const key of url.searchParams.keys()) if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + return transform?.(url.toString()) ?? url.toString() +} + +export const redactHeaders = ( + headers: Record, + allow: ReadonlyArray, + redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, +) => { + const allowed = new Set(allow.map((name) => name.toLowerCase())) + const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + return Object.fromEntries( + Object.entries(headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => allowed.has(name)) + .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) +} + +const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors + .map((redactor) => redactor.request) + .filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors + .map((redactor) => redactor.response) + .filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((value, fn) => fn(value), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((value, fn) => fn(value), snapshot), + } +} + +interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} +const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) +const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} +const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +const DEFAULT_REDACT_JSON_FIELDS = [ + "access_token", + "api_key", + "apikey", + "client_secret", + "password", + "refresh_token", + "secret", + "token", +] +const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() +interface RedactedJson { + readonly value: unknown + readonly changed: boolean +} +const redactJsonFields = (value: unknown, fields: ReadonlySet): RedactedJson => { + if (Array.isArray(value)) { + const items = value.map((item) => redactJsonFields(item, fields)) + return { value: items.map((item) => item.value), changed: items.some((item) => item.changed) } + } + if (!value || typeof value !== "object") return { value, changed: false } + let changed = false + const entries = Object.entries(value).map(([key, child]) => { + if (fields.has(normalizeField(key))) { + if (child !== REDACTED) changed = true + return [key, REDACTED] as const + } + const redacted = redactJsonFields(child, fields) + if (redacted.changed) changed = true + return [key, redacted.value] as const + }) + return { value: Object.fromEntries(entries), changed } +} +const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { + const redacted = Option.match(decodeJson(value), { + onNone: () => value, + onSome: (parsed) => { + const result = redactJsonFields(parsed, fields) + return result.changed ? JSON.stringify(result.value) : value + }, + }) + return transform?.(redacted) ?? redacted +} + +export const make = (options: RedactOptions = {}): Redactor => { + const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) + return compose( + requestHeaders({ + allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + responseHeaders({ + allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + url({ query: options.queryParameters, transform: options.url }), + { + request: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + response: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + }, + ) +} diff --git a/packages/http-recorder/src/redaction/secrets.ts b/packages/http-recorder/src/redaction/secrets.ts new file mode 100644 index 0000000000..b3ffd3c3a6 --- /dev/null +++ b/packages/http-recorder/src/redaction/secrets.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" + +const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, +] + +const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i +const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + +const envSecrets = () => + Object.entries(process.env).flatMap(([name, value]) => { + if (!value || !ENV_SECRET_NAMES.test(name) || value.length < 12 || SAFE_ENV_VALUES.has(value.toLowerCase())) + return [] + return [{ name, value }] + }) + +const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + +const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + if (typeof value === "string") return [{ path: base, value }] + if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + if (value && typeof value === "object") + return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + return [] +} + +export const SecretFindingSchema = Schema.Struct({ path: Schema.String, reason: Schema.String }) +export type SecretFinding = Schema.Schema.Type + +export const secretFindings = (value: unknown): ReadonlyArray => { + const environment = envSecrets() + return stringEntries(value).flatMap((entry) => [ + ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + path: entry.path, + reason: item.label, + })), + ...environment + .filter((item) => entry.value.includes(item.value)) + .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + ]) +} diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts deleted file mode 100644 index 582c647702..0000000000 --- a/packages/http-recorder/src/redactor.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Option } from "effect" -import { decodeJson } from "./matching.js" -import { REDACTED, redactHeaders, redactUrl } from "./redaction.js" -import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js" - -export type { RedactOptions } from "./types.js" - -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] - -const identity = (value: T) => value - -export interface Redactor { - readonly request: (snapshot: RequestSnapshot) => RequestSnapshot - readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot -} - -export const compose = (...redactors: ReadonlyArray>): Redactor => { - const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) - const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) - return { - request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), - response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), - } -} - -export interface HeaderOptions { - readonly allow?: ReadonlyArray - readonly redact?: ReadonlyArray -} - -export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), - }), -}) - -export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ - response: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), - }), -}) - -export interface UrlOptions { - readonly query?: ReadonlyArray - readonly transform?: (url: string) => string -} - -export const url = (options: UrlOptions = {}): Partial => ({ - request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), -}) - -export const body = (transform: (parsed: unknown) => unknown): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: (parsed) => JSON.stringify(transform(parsed)), - }), - }), -}) - -export interface DefaultRedactorOverrides { - readonly requestHeaders?: HeaderOptions - readonly responseHeaders?: HeaderOptions - readonly url?: UrlOptions - readonly body?: (parsed: unknown) => unknown -} - -const DEFAULT_REDACT_JSON_FIELDS = [ - "access_token", - "api_key", - "apikey", - "client_secret", - "password", - "refresh_token", - "secret", - "token", -] - -const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() - -const redactJsonFields = (value: unknown, fields: ReadonlySet): unknown => { - if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields)) - if (!value || typeof value !== "object") return value - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [ - key, - fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields), - ]), - ) -} - -const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { - const redacted = Option.match(decodeJson(value), { - onNone: () => value, - onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)), - }) - return transform?.(redacted) ?? redacted -} - -export const make = (options: RedactOptions = {}): Redactor => { - const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) - return compose( - requestHeaders({ - allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - responseHeaders({ - allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - url({ query: options.queryParameters, transform: options.url }), - { - request: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - response: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - }, - ) -} - -export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => - compose( - requestHeaders(overrides.requestHeaders), - responseHeaders(overrides.responseHeaders), - url(overrides.url), - ...(overrides.body ? [body(overrides.body)] : []), - ) diff --git a/packages/http-recorder/src/replay/comparison.ts b/packages/http-recorder/src/replay/comparison.ts new file mode 100644 index 0000000000..106dbd33a4 --- /dev/null +++ b/packages/http-recorder/src/replay/comparison.ts @@ -0,0 +1,29 @@ +import { Option, Schema } from "effect" +import { REDACTED } from "../redaction/redactor.js" +import { secretFindings } from "../redaction/secrets.js" + +export const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +export const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (isRecord(value)) + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, canonicalizeJson(value[key])]), + ) + return value +} + +export const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return typeof value + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +export const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) +export const isJsonRecord = isRecord diff --git a/packages/http-recorder/src/replay/state.ts b/packages/http-recorder/src/replay/state.ts new file mode 100644 index 0000000000..79f6edfb3a --- /dev/null +++ b/packages/http-recorder/src/replay/state.ts @@ -0,0 +1,96 @@ +import { Effect, Exit, HashSet, Ref, Scope, SynchronizedRef } from "effect" +import type { Interaction } from "../cassette/model.js" +import type { CassetteNotFoundError, Interface, InvalidCassetteError } from "../cassette/store.js" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = ( + cassette: Interface, + name: string, +): Effect.Effect<"record" | "replay" | "passthrough"> => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export interface ReplayState { + readonly claim: ( + validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} +export interface ReplayPoolState { + readonly claim: ( + select: (interactions: ReadonlyArray, used: HashSet.HashSet) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} + +export const makeReplayPoolState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const claimed = yield* SynchronizedRef.make(HashSet.empty()) + const attempted = yield* Ref.make(false) + yield* Effect.addFinalizer((exit) => + Exit.isFailure(exit) + ? Effect.void + : Effect.gen(function* () { + const used = yield* SynchronizedRef.get(claimed) + if (HashSet.isEmpty(used) && (yield* Ref.get(attempted))) return yield* Effect.void + const interactions = yield* load.pipe( + Effect.catchTag("CassetteNotFoundError", () => Effect.succeed([] as ReadonlyArray)), + Effect.orDie, + ) + if (HashSet.size(used) < interactions.length) + return yield* Effect.die( + new Error( + `Unused recorded interactions in ${name}: used ${HashSet.size(used)} of ${interactions.length}`, + ), + ) + return yield* Effect.void + }), + ) + return { + claim: (select) => + Ref.set(attempted, true).pipe( + Effect.andThen(load), + Effect.flatMap((interactions) => + SynchronizedRef.modifyEffect(claimed, (used) => + Effect.gen(function* () { + const index = yield* select(interactions, used) + const interaction = interactions[index] + if (interaction === undefined || HashSet.has(used, index)) + return yield* Effect.die("Replay selected an unavailable interaction") + return [{ interaction, index }, HashSet.add(used, index)] as const + }), + ), + ), + ), + } + }) + +export const makeReplayState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + makeReplayPoolState(cassette, name, project).pipe( + Effect.map((pool) => ({ + claim: (validate) => + pool.claim((interactions, used) => { + const index = HashSet.size(used) + return validate(interactions[index], index, interactions).pipe(Effect.as(index)) + }), + })), + ) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts deleted file mode 100644 index a9e6737804..0000000000 --- a/packages/http-recorder/src/schema.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Schema } from "effect" -import type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export const RequestSnapshotSchema = Schema.Struct({ - method: Schema.String, - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, -}) - -export const ResponseSnapshotSchema = Schema.Struct({ - status: Schema.Number, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, - bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), -}) - -export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) - -export const HttpInteractionSchema = Schema.Struct({ - transport: Schema.tag("http"), - request: RequestSnapshotSchema, - response: ResponseSnapshotSchema, -}) - -export const WebSocketEventSchema = Schema.Union([ - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("text"), - body: Schema.String, - }), - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("binary"), - body: Schema.String, - bodyEncoding: Schema.Literal("base64"), - }), -]) - -export const WebSocketInteractionSchema = Schema.Struct({ - transport: Schema.tag("websocket"), - open: Schema.Struct({ - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - }), - events: Schema.Array(WebSocketEventSchema), -}) - -export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( - Schema.toTaggedUnion("transport"), -) -export type Interaction = Schema.Schema.Type - -export const isHttpInteraction = InteractionSchema.guards.http - -export const isWebSocketInteraction = InteractionSchema.guards.websocket - -export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) - -export const webSocketInteractions = (interactions: ReadonlyArray) => - interactions.filter(isWebSocketInteraction) - -export const CassetteSchema = Schema.Struct({ - version: Schema.Literal(1), - metadata: Schema.optional(CassetteMetadataSchema), - interactions: Schema.Array(InteractionSchema), -}) -export type Cassette = Schema.Schema.Type - -export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) -export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/socket.ts b/packages/http-recorder/src/socket.ts deleted file mode 100644 index c7486db4aa..0000000000 --- a/packages/http-recorder/src/socket.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect" -import { Socket } from "effect/unstable/socket" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions } from "./schema.js" -import type { - RecorderOptions, - WebSocketEvent, - WebSocketInteraction, - WebSocketRecorderOptions, - WebSocketRequest, -} from "./types.js" - -interface ActiveReplay { - readonly interaction: WebSocketInteraction - readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> - readonly writeLock: Semaphore.Semaphore - readonly closed: Ref.Ref -} - -interface ActiveRecording { - readonly events: Array - readonly eventLock: Semaphore.Semaphore - readonly accepting: Ref.Ref - opened: boolean - valid: boolean -} - -type Frame = string | Uint8Array - -const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => - typeof message === "string" - ? { direction, kind: "text", body: message } - : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } - -const decodeEvent = (event: WebSocketEvent): Frame => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } -} - -const comparable = (event: WebSocketEvent, asJson: boolean) => { - if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) - const decoded = decodeJson(event.body) - return JSON.stringify( - canonicalizeJson({ - ...event, - body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value), - }), - ) -} - -const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return - throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => - Effect.suspend(() => { - const result = handler(value) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }) - -const runReplay = ( - state: ActiveReplay, - handler: (value: A) => Effect.Effect | void, - decode: (event: WebSocketEvent) => A, - onOpen: Effect.Effect | undefined, -) => - Effect.scoped( - Effect.gen(function* () { - const handlers = yield* FiberSet.make() - const run = yield* FiberSet.runtime(handlers)() - if (onOpen) yield* onOpen - - const drive = Effect.gen(function* () { - while (true) { - const current = yield* Ref.get(state.progress) - const event = state.interaction.events[current.position] - if (!event) return - if (yield* Ref.get(state.closed)) - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - if (event.direction === "server") { - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - run(runHandler(handler, decode(event))) - continue - } - yield* Deferred.await(current.changed) - } - }) - - yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) - yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) - }), - ) - -const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => { - const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" }) - return { url: snapshot.url, headers: snapshot.headers } -} - -const makeRecordingSocket = ( - upstream: Socket.Socket, - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -) => - Effect.gen(function* () { - const active = yield* Ref.make(undefined) - const writeLock = yield* Semaphore.make(1) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const state: ActiveRecording = { - events: [], - eventLock: yield* Semaphore.make(1), - accepting: yield* Ref.make(true), - opened: false, - valid: true, - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") - yield* upstream - .runRaw( - (message) => { - if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") - state.events.push(redactEvent(encodeEvent("server", message), redactor)) - return handler(message) - }, - { - ...runOptions, - onOpen: Effect.gen(function* () { - state.opened = true - if (runOptions?.onOpen) yield* runOptions.onOpen - }), - }, - ) - .pipe( - Effect.onExit((exit) => - writeLock.withPermit( - state.eventLock.withPermit( - Effect.gen(function* () { - yield* Ref.set(state.accepting, false) - yield* Ref.set(active, undefined) - if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return - yield* cassette - .append( - name, - { - transport: "websocket", - open: openSnapshot(request, redactor), - events: [...state.events], - }, - options.metadata, - ) - .pipe(Effect.orDie) - }), - ), - ), - ), - ) - }), - writer: upstream.writer.pipe( - Effect.map( - (write) => (message) => - writeLock.withPermit( - Effect.gen(function* () { - if (Socket.isCloseEvent(message)) return yield* write(message) - const state = yield* Ref.get(active) - if (!state || !(yield* Ref.get(state.accepting))) - return yield* Effect.die("WebSocket writer used without an active socket run") - const event = redactEvent(encodeEvent("client", message), redactor) - yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) - return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) - }), - ), - ), - ), - }) - }) - -const makeReplaySocket = ( - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -): Effect.Effect => - Effect.gen(function* () { - const replay = yield* makeReplayState(cassette, name, webSocketInteractions) - const active = yield* Ref.make(undefined) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = openSnapshot(request, redactor) - if ( - interaction && - JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) - ) - return - throw new Error( - `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`, - ) - }), - ) - .pipe(Effect.orDie) - const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make() }) - const writeLock = yield* Semaphore.make(1) - const state = { - interaction: claimed.interaction, - progress, - writeLock, - closed: yield* Ref.make(false), - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported") - yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( - Effect.ensuring(Ref.set(active, undefined)), - ) - }), - writer: Effect.succeed((message) => { - return Ref.get(active).pipe( - Effect.flatMap((state) => - state - ? state.writeLock.withPermit( - Effect.gen(function* () { - const current = yield* Ref.get(state.progress) - if (Socket.isCloseEvent(message)) { - yield* Ref.set(state.closed, true) - yield* Deferred.succeed(current.changed, undefined) - if (current.position === state.interaction.events.length) return - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - } - const actual = redactEvent(encodeEvent("client", message), redactor) - yield* assertEvent( - actual, - state.interaction.events[current.position], - current.position, - options.compareClientMessagesAsJson === true, - ) - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - yield* Deferred.succeed(current.changed, undefined) - }), - ) - : Effect.die("WebSocket writer used without an active socket run"), - ), - ) - }), - }) - }) - -const recordingLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - forcedMode?: "record" | "replay", -): Layer.Layer => - Layer.effect( - Socket.Socket, - Effect.gen(function* () { - const upstream = yield* Socket.Socket - const cassette = yield* CassetteService.Service - const redactor = make(options.redact) - if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") - return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor) - return yield* makeReplaySocket(cassette, name, request, options, redactor) - }), - ) - -/** - * Wraps a provided `Socket.Socket` with cassette recording and replay. - * - * Supply the ordinary URL-bound Effect socket layer beneath this decorator. - * The cassette name identifies the connection; recorder configuration does not - * duplicate the transport URL. - */ -export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer => - provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options) - -/** @internal */ -export const socketLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, -): Layer.Layer => - provideCassette(recordingLayer(name, request, options, options.mode), options) - -const provideCassette = ( - layer: Layer.Layer, - options: WebSocketRecorderOptions, -) => - layer.pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/types.ts b/packages/http-recorder/src/types.ts deleted file mode 100644 index 1ee0114205..0000000000 --- a/packages/http-recorder/src/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** Additional JSON metadata stored with a cassette. */ -export type CassetteMetadata = Record - -/** The normalized HTTP request representation used for matching. */ -export interface RequestSnapshot { - /** HTTP method. */ - readonly method: string - /** Fully qualified URL after redaction. */ - readonly url: string - /** Allowed and redacted request headers. */ - readonly headers: Record - /** Request body after redaction. */ - readonly body: string -} - -/** @internal */ -export interface ResponseSnapshot { - /** HTTP status code. */ - readonly status: number - /** Allowed and redacted response headers. */ - readonly headers: Record - /** Text body or base64-encoded binary body. */ - readonly body: string - /** Encoding used by `body`; omitted for ordinary text. */ - readonly bodyEncoding?: "text" | "base64" -} - -/** @internal */ -export interface HttpInteraction { - readonly transport: "http" - readonly request: RequestSnapshot - readonly response: ResponseSnapshot -} - -/** @internal */ -export type WebSocketEvent = - | { readonly direction: "client" | "server"; readonly kind: "text"; readonly body: string } - | { - readonly direction: "client" | "server" - readonly kind: "binary" - readonly body: string - readonly bodyEncoding: "base64" - } - -/** @internal */ -export interface WebSocketInteraction { - readonly transport: "websocket" - readonly open: { - readonly url: string - readonly headers: Record - } - readonly events: ReadonlyArray -} - -/** Returns whether an incoming HTTP request matches a recorded request. */ -export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean - -/** Additive redaction and header-preservation policy. */ -export interface RedactOptions { - /** Additional sensitive headers to retain as `[REDACTED]`. */ - readonly headers?: ReadonlyArray - /** Additional non-sensitive request headers to preserve for matching. */ - readonly allowRequestHeaders?: ReadonlyArray - /** Additional non-sensitive response headers to preserve for replay. */ - readonly allowResponseHeaders?: ReadonlyArray - /** Additional sensitive URL query parameter names. */ - readonly queryParameters?: ReadonlyArray - /** Additional JSON field names to redact recursively. */ - readonly jsonFields?: ReadonlyArray - /** Stabilizes a URL after built-in redaction. */ - readonly url?: (url: string) => string - /** Stabilizes a request, response, or text-frame body after built-in redaction. */ - readonly body?: (body: string) => string -} - -/** Options shared by HTTP recorder layers. */ -export interface RecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive redaction and header-preservation policy. */ - readonly redact?: RedactOptions - /** Custom HTTP request equivalence. */ - readonly match?: RequestMatcher -} - -/** @internal */ -export interface WebSocketRequest { - /** WebSocket URL. */ - readonly url: string - /** Headers used for redacted matching; the recorder does not send them. */ - readonly headers?: Record -} - -/** @internal */ -export interface WebSocketRecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive handshake and text-frame redaction policy. */ - readonly redact?: RedactOptions - /** Compare text client frames as canonical JSON instead of exact strings. */ - readonly compareClientMessagesAsJson?: boolean - /** WebSocket subprotocols used by `layerWebSocket`. */ - readonly protocols?: string | Array -} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts deleted file mode 100644 index 869cd3bf90..0000000000 --- a/packages/http-recorder/src/websocket.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect" -import type { Headers } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import type { RecordReplayMode } from "./internal-effect.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js" - -export interface WebSocketRequest { - readonly url: string - readonly headers: Headers.Headers -} - -export interface WebSocketConnection { - readonly sendText: (message: string) => Effect.Effect - readonly messages: Stream.Stream - readonly close: Effect.Effect -} - -export interface WebSocketExecutor { - readonly open: (request: WebSocketRequest) => Effect.Effect, E> -} - -export interface WebSocketRecordReplayOptions { - readonly name: string - readonly mode?: RecordReplayMode - readonly metadata?: CassetteMetadata - readonly cassette: CassetteService.Interface - readonly live: WebSocketExecutor - readonly redactor?: Redactor - readonly compareClientMessagesAsJson?: boolean -} - -const headersRecord = (headers: Headers.Headers): Record => - Object.fromEntries( - Object.entries(headers as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ) - -const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ - direction, - kind: "text", - body, -}) - -const decodeEvent = (event: WebSocketEvent) => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) - -const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - const matches = - expected?.direction === "client" && - expected.kind === "text" && - JSON.stringify(asJson ? jsonOrText(actual) : actual) === - JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body) - if (matches) return - throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -export const makeWebSocketExecutor = ( - options: WebSocketRecordReplayOptions, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name)) - const redactor = options.redactor ?? make() - const openSnapshot = (request: WebSocketRequest) => { - const snapshot = redactor.request({ - method: "GET", - url: request.url, - headers: headersRecord(request.headers), - body: "", - }) - return { url: snapshot.url, headers: snapshot.headers } - } - const redactEvent = (event: WebSocketEvent) => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } - } - - if (mode === "passthrough") return options.live - - if (mode === "record") { - return { - open: (request) => - Effect.gen(function* () { - const events: WebSocketEvent[] = [] - const connection = yield* options.live.open(request) - const closed = yield* Ref.make(false) - const closeLock = yield* Semaphore.make(1) - return { - sendText: (message) => - Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe( - Effect.andThen(connection.sendText(message)), - ), - messages: connection.messages.pipe( - Stream.tap((message) => - Effect.sync(() => - events.push( - typeof message === "string" - ? redactEvent(textEvent("server", message)) - : { - direction: "server", - kind: "binary", - body: Buffer.from(message).toString("base64"), - bodyEncoding: "base64", - }, - ), - ), - ), - ), - close: closeLock.withPermit( - Effect.gen(function* () { - if (yield* Ref.get(closed)) return - yield* connection.close - yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request), events }, - options.metadata, - ) - .pipe(Effect.orDie) - yield* Ref.set(closed, true) - }), - ), - } - }), - } - } - - const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) - return { - open: (request) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = canonicalizeJson(openSnapshot(request)) - if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open))) - return - throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) - }), - ) - .pipe(Effect.orDie) - const client = claimed.interaction.events.filter((event) => event.direction === "client") - const server = claimed.interaction.events.filter((event) => event.direction === "server") - const position = yield* SynchronizedRef.make(0) - return { - sendText: (message) => - SynchronizedRef.updateEffect(position, (index) => - assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe( - Effect.as(index + 1), - ), - ), - messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)), - close: Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used !== client.length) - return yield* Effect.die( - new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`), - ) - }), - } - }), - } - }) diff --git a/packages/http-recorder/src/websocket/model.ts b/packages/http-recorder/src/websocket/model.ts new file mode 100644 index 0000000000..b5930141bb --- /dev/null +++ b/packages/http-recorder/src/websocket/model.ts @@ -0,0 +1,35 @@ +import { Schema } from "effect" + +export const WebSocketEventSchema = Schema.Union([ + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("text"), + body: Schema.String, + }), + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("binary"), + body: Schema.String, + bodyEncoding: Schema.Literal("base64"), + }), +]) + +export type WebSocketEvent = Schema.Schema.Type + +export const WebSocketInteractionSchema = Schema.Struct({ + transport: Schema.tag("websocket"), + connection: Schema.optional( + Schema.Struct({ + sequence: Schema.Number, + url: Schema.String, + protocols: Schema.Array(Schema.String), + close: Schema.Struct({ + code: Schema.Number, + reason: Schema.String, + }), + }), + ), + events: Schema.Array(WebSocketEventSchema), +}) + +export interface WebSocketInteraction extends Schema.Schema.Type {} diff --git a/packages/http-recorder/src/websocket/recorder.ts b/packages/http-recorder/src/websocket/recorder.ts new file mode 100644 index 0000000000..3d232c4db9 --- /dev/null +++ b/packages/http-recorder/src/websocket/recorder.ts @@ -0,0 +1,584 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect" +import { Socket } from "effect/unstable/socket" +import { fileSystem, type Interface, Service } from "../cassette/store.js" +import type { SocketRecorderOptions } from "../options.js" +import { make, type Redactor } from "../redaction/redactor.js" +import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js" +import { makeReplayState, resolveAutoMode } from "../replay/state.js" +import { webSocketInteractions, type Interaction } from "../cassette/model.js" +import type { WebSocketEvent, WebSocketInteraction } from "./model.js" + +interface WebSocketRecorderOptions extends SocketRecorderOptions { + readonly compareClientMessagesAsJson?: boolean +} +interface ActiveReplay { + readonly interaction: WebSocketInteraction + readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> + readonly writeLock: Semaphore.Semaphore + readonly closed: Ref.Ref +} +interface ActiveRecording { + readonly events: Array + readonly eventLock: Semaphore.Semaphore + readonly accepting: Ref.Ref + opened: boolean + valid: boolean +} +interface PendingRecordings { + readonly promises: Set> + readonly errors: Array +} +type Frame = string | Uint8Array + +const normalizeProtocols = (protocols?: string | Array): Array => + protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols] +const frameFromWebSocketData = async (data: unknown): Promise => { + if (typeof data === "string") return data + if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer()) + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice() + throw new Error(`Unsupported WebSocket frame: ${Object.prototype.toString.call(data)}`) +} +const closeEvent = (code: number, reason: string): CloseEvent => { + if (typeof globalThis.CloseEvent === "function") + return new globalThis.CloseEvent("close", { code, reason, wasClean: code === 1000 }) + const event = new Event("close") + Object.defineProperties(event, { + code: { value: code }, + reason: { value: reason }, + wasClean: { value: code === 1000 }, + }) + return event as CloseEvent +} +const errorEvent = (error: unknown): ErrorEvent => { + if (typeof globalThis.ErrorEvent === "function") + return new globalThis.ErrorEvent("error", { + error, + message: error instanceof Error ? error.message : String(error), + }) + const event = new Event("error") + Object.defineProperties(event, { + error: { value: error }, + message: { value: error instanceof Error ? error.message : String(error) }, + }) + return event as ErrorEvent +} +const webSocketFacade = ( + target: EventTarget, + properties: { + readonly url: () => string + readonly readyState: () => number + readonly protocol: () => string + readonly extensions: () => string + readonly bufferedAmount: () => number + readonly send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void + readonly close: (code?: number, reason?: string) => void + }, +): globalThis.WebSocket => { + Object.defineProperties(target, { + url: { get: properties.url }, + readyState: { get: properties.readyState }, + protocol: { get: properties.protocol }, + extensions: { get: properties.extensions }, + bufferedAmount: { get: properties.bufferedAmount }, + binaryType: { value: "blob", writable: true }, + send: { value: properties.send }, + close: { value: properties.close }, + CONNECTING: { value: 0 }, + OPEN: { value: 1 }, + CLOSING: { value: 2 }, + CLOSED: { value: 3 }, + }) + for (const name of ["open", "message", "error", "close"] as const) { + let handler: ((event: Event) => unknown) | null = null + Object.defineProperty(target, `on${name}`, { + get: () => handler, + set: (next) => { + if (handler) target.removeEventListener(name, handler) + handler = typeof next === "function" ? next : null + if (handler) target.addEventListener(name, handler) + }, + }) + } + return target as globalThis.WebSocket +} + +const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => + typeof message === "string" + ? { direction, kind: "text", body: message } + : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } +const decodeEvent = (event: WebSocketEvent): Frame => + event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) +const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { + if (event.kind === "binary") return event + const body = + event.direction === "client" + ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body + : redactor.response({ status: 101, headers: {}, body: event.body }).body + return { ...event, body } +} +const comparable = (event: WebSocketEvent, asJson: boolean) => { + if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) + const decoded = decodeJson(event.body) + return JSON.stringify( + canonicalizeJson({ ...event, body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value) }), + ) +} +const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => + Effect.sync(() => { + if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return + throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) + }) +const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => + Effect.suspend(() => { + const result = handler(value) + return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void + }) +const runReplay = ( + state: ActiveReplay, + handler: (value: A) => Effect.Effect | void, + decode: (event: WebSocketEvent) => A, + onOpen: Effect.Effect | undefined, +) => + Effect.scoped( + Effect.gen(function* () { + const handlers = yield* FiberSet.make() + const run = yield* FiberSet.runtime(handlers)() + if (onOpen) yield* onOpen + const drive = Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(state.progress) + const event = state.interaction.events[current.position] + if (!event) return + if (yield* Ref.get(state.closed)) + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + if (event.direction === "server") { + yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make() }) + run(runHandler(handler, decode(event))) + continue + } + yield* Deferred.await(current.changed) + } + }) + yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) + yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) + }), + ) + +const makeRecordingSocket = ( + upstream: Socket.Socket, + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +) => + Effect.gen(function* () { + const active = yield* Ref.make(undefined) + const writeLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + Effect.gen(function* () { + const state: ActiveRecording = { + events: [], + eventLock: yield* Semaphore.make(1), + accepting: yield* Ref.make(true), + opened: false, + valid: true, + } + const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) + if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") + yield* upstream + .runRaw( + (message) => { + if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") + state.events.push(redactEvent(encodeEvent("server", message), redactor)) + return handler(message) + }, + { + ...runOptions, + onOpen: Effect.gen(function* () { + state.opened = true + if (runOptions?.onOpen) yield* runOptions.onOpen + }), + }, + ) + .pipe( + Effect.onExit((exit) => + writeLock.withPermit( + state.eventLock.withPermit( + Effect.gen(function* () { + yield* Ref.set(state.accepting, false) + yield* Ref.set(active, undefined) + if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return + yield* cassette + .append( + name, + { + transport: "websocket", + events: [...state.events], + }, + options.metadata, + ) + .pipe(Effect.orDie) + }), + ), + ), + ), + ) + }), + writer: upstream.writer.pipe( + Effect.map( + (write) => (message) => + writeLock.withPermit( + Effect.gen(function* () { + if (Socket.isCloseEvent(message)) return yield* write(message) + const state = yield* Ref.get(active) + if (!state || !(yield* Ref.get(state.accepting))) + return yield* Effect.die("WebSocket writer used without an active socket run") + const event = redactEvent(encodeEvent("client", message), redactor) + yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) + return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) + }), + ), + ), + ), + }) + }) + +const makeReplaySocket = ( + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, webSocketInteractions) + const active = yield* Ref.make(undefined) + const runLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + runLock + .withPermitsIfAvailable(1)( + Effect.gen(function* () { + const claimed = yield* replay + .claim((interaction) => + interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"), + ) + .pipe(Effect.orDie) + const state = { + interaction: claimed.interaction, + progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make() }), + writeLock: yield* Semaphore.make(1), + closed: yield* Ref.make(false), + } + yield* Ref.set(active, state) + yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( + Effect.ensuring(Ref.set(active, undefined)), + ) + }), + ) + .pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"), + onSome: () => Effect.void, + }), + ), + ), + writer: Effect.succeed((message) => + Ref.get(active).pipe( + Effect.flatMap((state) => + state + ? state.writeLock.withPermit( + Effect.gen(function* () { + const current = yield* Ref.get(state.progress) + if (Socket.isCloseEvent(message)) { + yield* Ref.set(state.closed, true) + yield* Deferred.succeed(current.changed, undefined) + if (current.position === state.interaction.events.length) return + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + } + const actual = redactEvent(encodeEvent("client", message), redactor) + yield* assertEvent( + actual, + state.interaction.events[current.position], + current.position, + options.compareClientMessagesAsJson === true, + ) + yield* Ref.set(state.progress, { + position: current.position + 1, + changed: yield* Deferred.make(), + }) + yield* Deferred.succeed(current.changed, undefined) + }), + ) + : Effect.die("WebSocket writer used without an active socket run"), + ), + ), + ), + }) + }) + +const recordingLayer = ( + name: string, + options: WebSocketRecorderOptions, + forcedMode?: "record" | "replay", +): Layer.Layer => + Layer.effect( + Socket.Socket, + Effect.gen(function* () { + const upstream = yield* Socket.Socket + const cassette = yield* Service + const redactor = make(options.redact) + if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") + return yield* makeRecordingSocket(upstream, cassette, name, options, redactor) + return yield* makeReplaySocket(cassette, name, options, redactor) + }), + ) + +export const layerSocket = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette(recordingLayer(name, { ...options, compareClientMessagesAsJson: true }), options) +/** @internal */ +export const layerSocketWithMode = ( + name: string, + options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, +): Layer.Layer => + provideCassette(recordingLayer(name, options, options.mode), options) +const provideCassette = (layer: Layer.Layer, options: WebSocketRecorderOptions) => + layer.pipe(Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(NodeFileSystem.layer)) + +const makeRecordingWebSocketConstructor = ( + upstream: Socket.WebSocketConstructor["Service"], + cassette: Interface, + name: string, + metadata: SocketRecorderOptions["metadata"], + redactor: Redactor, + pending: PendingRecordings, +): Socket.WebSocketConstructor["Service"] => { + let nextSequence = 0 + return (url, protocols) => { + const sequence = nextSequence++ + const requestedProtocols = normalizeProtocols(protocols) + const native = upstream(url, requestedProtocols) + const events: WebSocketEvent[] = [] + let opened = false + let failed = false + let closed = false + let queue = Promise.resolve() + const appendEvent = (direction: "client" | "server", data: unknown) => { + queue = queue.then(async () => { + if (failed || closed) return + try { + events.push(redactEvent(encodeEvent(direction, await frameFromWebSocketData(data)), redactor)) + } catch { + failed = true + } + }) + } + const onOpen = () => { + opened = true + } + const onMessage = (event: MessageEvent) => { + appendEvent("server", event.data) + } + const onError = () => { + failed = true + } + const onClose = (event: CloseEvent) => { + native.removeEventListener("open", onOpen) + native.removeEventListener("message", onMessage) + native.removeEventListener("error", onError) + native.removeEventListener("close", onClose) + const completion = queue.then(async () => { + closed = true + if (opened && !failed) { + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + const interaction: WebSocketInteraction = { + transport: "websocket", + connection: { + sequence, + url: request.url, + protocols: requestedProtocols, + close: { code: event.code, reason: event.reason }, + }, + events: [...events], + } + events.length = 0 + await Effect.runPromise(cassette.append(name, interaction, metadata).pipe(Effect.orDie)) + } + }) + pending.promises.add(completion) + void completion.then( + () => pending.promises.delete(completion), + (error) => { + pending.promises.delete(completion) + pending.errors.push(error) + }, + ) + } + native.addEventListener("open", onOpen) + native.addEventListener("message", onMessage) + native.addEventListener("error", onError) + native.addEventListener("close", onClose) + return new Proxy(native, { + get: (target, property) => { + if (property === "send") + return (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + Reflect.apply(target.send, target, [data]) + appendEvent("client", data) + } + const value: unknown = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + set: (target, property, value) => Reflect.set(target, property, value, target), + }) + } +} + +const constructorWebSocketInteractions = (interactions: ReadonlyArray) => + webSocketInteractions(interactions) + .filter((interaction) => interaction.connection !== undefined) + .map((interaction, index) => ({ interaction, index })) + .toSorted((a, b) => a.interaction.connection!.sequence - b.interaction.connection!.sequence) + .map(({ interaction }) => interaction) + +const makeReplayWebSocketConstructor = ( + cassette: Interface, + name: string, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, constructorWebSocketInteractions) + return (url, protocols) => { + const target = new EventTarget() + const requestedProtocols = normalizeProtocols(protocols) + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + let readyState = 0 + let interaction: WebSocketInteraction | undefined + let position = 0 + let finished = false + let closeRequested = false + let operations = Promise.resolve() + const fail = (error: unknown) => { + if (finished) return + finished = true + readyState = 3 + target.dispatchEvent(errorEvent(error)) + } + const finish = () => { + if (finished || !interaction || position !== interaction.events.length) return + finished = true + readyState = 3 + const terminal = interaction.connection?.close ?? { code: 1000, reason: "" } + target.dispatchEvent(closeEvent(terminal.code, terminal.reason)) + } + const drive = () => { + if (!interaction || finished) return + while (interaction.events[position]?.direction === "server") { + const event = interaction.events[position++] + if (!event) break + target.dispatchEvent(new MessageEvent("message", { data: decodeEvent(event) })) + } + if (position === interaction.events.length) setTimeout(finish, 0) + } + Effect.runPromise( + replay + .claim((recorded, index) => + Effect.sync(() => { + if (!recorded) throw new Error(`Missing recorded WebSocket connection ${index + 1}`) + const connection = recorded.connection + if (!connection) throw new Error(`WebSocket interaction ${index + 1} has no connection metadata`) + if (connection.url !== request.url) + throw new Error( + `WebSocket connection ${index + 1}: expected URL ${safeText(connection.url)}, received ${safeText(request.url)}`, + ) + if ( + connection.protocols.length !== requestedProtocols.length || + connection.protocols.some((protocol, protocolIndex) => protocol !== requestedProtocols[protocolIndex]) + ) + throw new Error( + `WebSocket connection ${index + 1}: expected protocols ${safeText(connection.protocols)}, received ${safeText(requestedProtocols)}`, + ) + }), + ) + .pipe(Effect.orDie), + ).then((claimed) => { + if (closeRequested) return fail(new Error("WebSocket closed before it opened")) + interaction = claimed.interaction + readyState = 1 + target.dispatchEvent(new Event("open")) + drive() + }, fail) + return webSocketFacade(target, { + url: () => url, + readyState: () => readyState, + protocol: () => requestedProtocols[0] ?? "", + extensions: () => "", + bufferedAmount: () => 0, + send: (data) => { + if (!interaction || readyState !== 1 || closeRequested) throw new Error("WebSocket is not open") + operations = operations.then(async () => { + try { + const frame = await frameFromWebSocketData(data) + const actual = redactEvent(encodeEvent("client", frame), redactor) + Effect.runSync(assertEvent(actual, interaction?.events[position], position, true)) + position += 1 + drive() + } catch (error) { + fail(error) + } + }) + }, + close: () => { + if (closeRequested || readyState === 3) return + closeRequested = true + readyState = 2 + operations = operations.then(() => { + if (!interaction) return + if (position !== interaction.events.length) + return fail( + new Error(`WebSocket closed with unconsumed events: used ${position} of ${interaction.events.length}`), + ) + finish() + }) + }, + }) + } + }) + +export const layerWebSocketConstructor = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette( + Layer.effect( + Socket.WebSocketConstructor, + Effect.gen(function* () { + const upstream = yield* Socket.WebSocketConstructor + const cassette = yield* Service + const redactor = make(options.redact) + if ((yield* resolveAutoMode(cassette, name)) === "replay") + return yield* makeReplayWebSocketConstructor(cassette, name, redactor) + const pending: PendingRecordings = { promises: new Set(), errors: [] } + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.all(pending.promises)).pipe( + Effect.flatMap(() => (pending.errors.length === 0 ? Effect.void : Effect.die(pending.errors[0]))), + ), + ) + return makeRecordingWebSocketConstructor(upstream, cassette, name, options.metadata, redactor, pending) + }), + ), + options, + ) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts index 64441936d7..f25b971455 100644 --- a/packages/http-recorder/sst-env.d.ts +++ b/packages/http-recorder/sst-env.d.ts @@ -7,4 +7,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/packages/http-recorder/test/cassette.test.ts b/packages/http-recorder/test/cassette.test.ts new file mode 100644 index 0000000000..d15e7628de --- /dev/null +++ b/packages/http-recorder/test/cassette.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { existsSync, readdirSync, writeFileSync } from "node:fs" +import type { Interaction } from "../src/cassette/model" +import { HttpRecorder } from "../src" +import { Service, hasCassetteSync, memory } from "../src/cassette/store" +import { cassetteLayer } from "../src/http/recorder" +import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support" + +describe("cassette", () => { + test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { + using server = Bun.serve({ + port: 0, + fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234"), + }) + const url = `http://127.0.0.1:${server.port}/leaky` + using directory = tempDirectory("http-recorder-unsafe-") + + const exit = await Effect.runPromise( + Effect.exit( + post(url, { ok: true }).pipe( + Effect.provide( + cassetteLayer("unsafe-record", { + directory: directory.path, + mode: "record", + }), + ), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("contains possible secrets") + expect(existsSync(`${directory.path}/unsafe-record.json`)).toBe(false) + }) + + test("failed memory appends leave cassette state unchanged", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* Service + const interaction: Interaction = { + transport: "http", + request: { + method: "GET", + url: "https://example.test", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "safe" }, + } + yield* cassette.append("transactional", interaction) + yield* cassette + .append("transactional", { + ...interaction, + response: { + ...interaction.response, + body: "Bearer abcdefghijklmnopqrstuvwxyz1234", + }, + }) + .pipe(Effect.flip) + + expect(yield* cassette.read("transactional")).toEqual([interaction]) + }).pipe(Effect.provide(memory())), + ) + }) + + test("concurrent file appends preserve every interaction", async () => { + using directory = tempDirectory("http-recorder-concurrent-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach( + Array.from({ length: 20 }, (_, index) => index), + (index) => + cassette.append("concurrent", { + transport: "http", + request: { + method: "GET", + url: `https://example.test/${index}`, + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: String(index) }, + }), + { concurrency: "unbounded" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/concurrent.json`) + expect(cassette.interactions).toHaveLength(20) + expect(readdirSync(directory.path).filter((file) => file.endsWith(".tmp"))).toEqual([]) + }) + + test("generated metadata cannot be overridden", async () => { + using directory = tempDirectory("http-recorder-metadata-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* cassette.append( + "metadata", + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + { name: "wrong", recordedAt: "wrong" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/metadata.json`) + expect(cassette.metadata?.name).toBe("metadata") + expect(cassette.metadata?.recordedAt).not.toBe("wrong") + }) + + test("reports malformed cassettes as invalid", async () => { + using directory = tempDirectory("http-recorder-invalid-") + writeFileSync(`${directory.path}/invalid.json`, "{not-json") + + const error = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.read("invalid").pipe(Effect.flip) + }), + ) + + expect(error._tag).toBe("InvalidCassetteError") + }) + + test("rejects cassette paths outside the recordings directory", () => { + using directory = tempDirectory("http-recorder-path-") + expect(() => hasCassetteSync("../outside", { directory: directory.path })).toThrow("Invalid cassette name") + expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name") + }) + + test("public cassette lifecycle helpers check and remove a recording", async () => { + using directory = tempDirectory("http-recorder-lifecycle-") + const options = { directory: directory.path } + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + + await seedCassetteDirectory(directory.path, "nested/example", [ + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + ]) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(true) + + HttpRecorder.removeCassetteSync("nested/example", options) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + expect(() => HttpRecorder.removeCassetteSync("nested/example", options)).not.toThrow() + expect(() => HttpRecorder.removeCassetteSync("../outside", options)).toThrow("Invalid cassette name") + }) + + test("Cassette.list enumerates recorded cassette names", async () => { + using directory = tempDirectory("http-recorder-list-") + await seedCassetteDirectory(directory.path, "alpha/one", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/a", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "a" }, + }, + ]) + await seedCassetteDirectory(directory.path, "beta", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/b", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "b" }, + }, + ]) + + const names = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.list() + }), + ) + expect(names).toEqual(["alpha/one", "beta"]) + }) +}) diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/http/multi-step.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json rename to packages/http-recorder/test/fixtures/recordings/http/multi-step.json diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/http/retry.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/retry.json rename to packages/http-recorder/test/fixtures/recordings/http/retry.json diff --git a/packages/http-recorder/test/http.test.ts b/packages/http-recorder/test/http.test.ts new file mode 100644 index 0000000000..ebba965c7d --- /dev/null +++ b/packages/http-recorder/test/http.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { existsSync } from "node:fs" +import { isHttpInteraction } from "../src/cassette/model" +import { HttpRecorder } from "../src" +import { failureText, post, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("http/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecorderOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options)))) + +describe("HTTP", () => { + test("decorates a provided HTTP client", async () => { + await Effect.runPromise( + Effect.all([post("https://example.test/echo", { step: 1 }), post("https://example.test/echo", { step: 2 })]).pipe( + Effect.provide(HttpRecorder.layer("http/multi-step")), + Effect.provide(FetchHttpClient.layer), + ), + ) + }) + + test("replay returns recorded responses in order for identical requests", async () => { + await runWith( + "http/retry", + {}, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') + }), + ) + }) + + test("replay reports exhaustion when more requests are made than recorded", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + yield* post("https://example.test/echo", { step: 2 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + test("a mismatch does not consume an interaction", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("$.step expected 2, received 3") + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + }), + ) + }) + + test("distinct requests replay in any order", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + + test("concurrent distinct requests atomically claim their matching interactions", async () => { + const results = await run( + Effect.all([post("https://example.test/echo", { step: 2 }), post("https://example.test/echo", { step: 1 })], { + concurrency: "unbounded", + }), + ) + + expect(results).toEqual(['{"reply":"second"}', '{"reply":"first"}']) + }) + + test("concurrent replay claims each interaction once", async () => { + const results = await runWith( + "http/retry", + {}, + Effect.all( + [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], + { concurrency: "unbounded" }, + ), + ) + + expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) + }) + + test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { + await run( + Effect.gen(function* () { + const exit = yield* Effect.exit( + post("https://example.test/echo?api_key=secret-value", { + step: 3, + token: "sk-123456789012345678901234", + }), + ) + const message = failureText(exit) + expect(message).toContain("url:") + expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") + expect(message).toContain("body:") + expect(message).toContain("$.step expected 1, received 3") + expect(message).toContain('$.token expected undefined, received "[REDACTED]"') + expect(message).not.toContain("sk-123456789012345678901234") + }), + ) + }) + + test("applies custom URL redaction to mismatch errors", async () => { + const secret = "private-account" + const exit = await Effect.runPromiseExit( + post(`https://example.test/${secret}`, { step: 1 }).pipe( + Effect.provide( + HttpRecorder.layerFetch("http/multi-step", { + redact: { url: (url) => url.replace(secret, "{account}") }, + }), + ), + ), + ) + const message = failureText(exit) + + expect(message).toContain("https://example.test/{account}") + expect(message).not.toContain(secret) + }) + + test("fails when a non-empty replay cassette is completely unused", async () => { + const exit = await Effect.runPromiseExit( + Effect.void.pipe(Effect.scoped, Effect.provide(HttpRecorder.layerFetch("http/multi-step"))), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("Unused recorded interactions in http/multi-step: used 0 of 2") + }) + + test("allows an unused replay layer when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-unused-missing-") + await withEnvironment("CI", "true", () => + Effect.runPromise( + Effect.void.pipe( + Effect.scoped, + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + }) + + describe("auto mode", () => { + test("replays when the cassette exists", async () => { + using directory = tempDirectory("http-recorder-auto-") + await seedCassetteDirectory(directory.path, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { + status: 200, + headers: { "content-type": "application/json" }, + body: '{"reply":"hi"}', + }, + }, + ]) + + const result = await runWith( + "auto-replay", + { directory: directory.path }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("forces replay when CI=true even if cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-ci-") + await withEnvironment("CI", "true", async () => { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + }) + }) + + test("records to disk when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-record-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response('{"reply":"recorded"}', { + headers: { "content-type": "application/json" }, + }), + }) + const url = `http://127.0.0.1:${server.port}/echo` + await withEnvironment("CI", undefined, async () => { + const result = await runWith("auto-record", { directory: directory.path }, post(url, { step: 1 })) + expect(result).toBe('{"reply":"recorded"}') + expect(existsSync(`${directory.path}/auto-record.json`)).toBe(true) + }) + }) + + test("records concurrent requests in request-start order", async () => { + using directory = tempDirectory("http-recorder-order-") + const first = Promise.withResolvers() + const completed: string[] = [] + using server = Bun.serve({ + port: 0, + fetch: async (request) => { + const name = new URL(request.url).pathname.slice(1) + if (name === "first") { + await first.promise + completed.push(name) + return new Response(name) + } + completed.push(name) + first.resolve() + return new Response(name) + }, + }) + await withEnvironment("CI", undefined, async () => { + const request = (name: string) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) + return yield* response.text + }) + const responses = await Effect.runPromise( + Effect.all([request("first"), request("second")], { + concurrency: "unbounded", + }).pipe(Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/concurrent-order.json`) + + expect(completed).toEqual(["second", "first"]) + expect(responses).toEqual(["first", "second"]) + expect(cassette.interactions.filter(isHttpInteraction).map((interaction) => interaction.request.url)).toEqual([ + `http://127.0.0.1:${server.port}/first`, + `http://127.0.0.1:${server.port}/second`, + ]) + }) + }) + + test("returns the live response while persisting its redacted snapshot", async () => { + using directory = tempDirectory("http-recorder-live-response-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { + headers: { + "content-type": "application/json", + "x-request-id": "request-1", + }, + }), + }) + await withEnvironment("CI", undefined, async () => { + const body = await runWith( + "live-response", + { directory: directory.path }, + post(`http://127.0.0.1:${server.port}/response`, { ok: true }), + ) + const cassette = readCassette(`${directory.path}/live-response.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(body).toBe('{"access_token":"live-secret","safe":true}') + expect(interaction?.response.body).toBe('{"access_token":"[REDACTED]","safe":true}') + }) + }) + + test("reconstructs responses with null-body statuses", async () => { + using directory = tempDirectory("http-recorder-no-content-") + using server = Bun.serve({ + port: 0, + fetch: () => new Response(null, { status: 204 }), + }) + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) + }) + const response = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory: directory.path }))), + ) + + expect(response.status).toBe(204) + }) + }) + + test("records and replays arbitrary binary responses without changing bytes", async () => { + using directory = tempDirectory("http-recorder-binary-") + const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) + using server = Bun.serve({ + port: 0, + fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), + }) + const url = `http://127.0.0.1:${server.port}/image.png` + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(url)) + return new Uint8Array(yield* response.arrayBuffer) + }) + const record = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + await server.stop() + const replay = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/binary.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(record).toEqual(expected) + expect(replay).toEqual(expected) + expect(interaction?.response.bodyEncoding).toBe("base64") + }) + }) + }) +}) diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts deleted file mode 100644 index 93d7bbaded..0000000000 --- a/packages/http-recorder/test/record-replay.test.ts +++ /dev/null @@ -1,879 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { describe, expect, test } from "bun:test" -import { Cause, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" -import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Socket } from "effect/unstable/socket" -import * as fs from "node:fs" -import * as os from "node:os" -import * as path from "node:path" -import { HttpRecorder } from "../src" -import { HttpRecorderInternal } from "../src/internal" -import { redactedErrorRequest } from "../src/internal-effect" -import type { Interaction } from "../src/schema" - -const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => - Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - -const post = (url: string, body: object) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const request = HttpClientRequest.post(url, { - headers: { "content-type": "application/json" }, - body: HttpBody.text(JSON.stringify(body), "application/json"), - }) - const response = yield* http.execute(request) - return yield* response.text - }) - -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http("record-replay/multi-step")))) - -const runWith = ( - name: string, - options: HttpRecorder.RecorderOptions, - effect: Effect.Effect, -) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http(name, options)))) - -const runRecorder = (effect: Effect.Effect) => - Effect.runPromise( - Effect.scoped( - effect.pipe( - Effect.provide( - HttpRecorderInternal.Cassette.fileSystem({ - directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")), - }), - ), - Effect.provide(NodeFileSystem.layer), - ), - ), - ) - -const failureText = (exit: Exit.Exit) => { - if (Exit.isSuccess(exit)) return "" - return Cause.prettyErrors(exit.cause).join("\n") -} - -describe("http-recorder", () => { - test("redacts sensitive URL query parameters", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", - ), - ).toBe( - "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", - ) - }) - - test("redacts URL credentials", () => { - expect(HttpRecorderInternal.redactUrl("https://user:password@example.test/path?safe=value")).toBe( - "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", - ) - }) - - test("applies custom URL redaction after built-in redaction", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/accounts/real-account/path?key=secret-key", - undefined, - (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"), - ), - ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") - }) - - test("redacts sensitive headers when allow-listed", () => { - expect( - HttpRecorderInternal.redactHeaders( - { - authorization: "Bearer secret-token", - "content-type": "application/json", - "x-custom-token": "custom-secret", - "x-api-key": "secret-key", - "x-goog-api-key": "secret-google-key", - }, - ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], - ["x-custom-token"], - ), - ).toEqual({ - authorization: "[REDACTED]", - "content-type": "application/json", - "x-api-key": "[REDACTED]", - "x-custom-token": "[REDACTED]", - "x-goog-api-key": "[REDACTED]", - }) - }) - - test("redacts error requests without retaining headers, params, or body", () => { - const request = HttpClientRequest.post("https://example.test/path", { - headers: { authorization: "Bearer super-secret" }, - body: HttpBody.text("super-secret-body", "text/plain"), - }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) - - expect(redactedErrorRequest(request).toJSON()).toMatchObject({ - url: "https://example.test/path", - urlParams: { params: [] }, - headers: {}, - body: { _tag: "Empty" }, - }) - }) - - test("detects secret-looking values without returning the secret", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - interactions: [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/path?key=sk-123456789012345678901234", - headers: {}, - body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }), - }, - response: { - status: 200, - headers: {}, - body: "Bearer abcdefghijklmnopqrstuvwxyz", - }, - }, - ], - }), - ).toEqual([ - { path: "interactions[0].request.url", reason: "API key" }, - { path: "interactions[0].request.body", reason: "Google API key" }, - { path: "interactions[0].response.body", reason: "bearer token" }, - ]) - }) - - test("detects secret-looking values inside metadata", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - metadata: { token: "sk-123456789012345678901234" }, - interactions: [], - }), - ).toEqual([{ path: "metadata.token", reason: "API key" }]) - }) - - test("redacts configured and common sensitive JSON fields", () => { - const redactor = HttpRecorderInternal.Redactor.make({ jsonFields: ["account_id"] }) - const request = redactor.request({ - method: "POST", - url: "https://example.test/path", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - password: "secret-password", - accessToken: "access-token", - nested: { account_id: "account-123", safe: "visible" }, - }), - }) - - expect(JSON.parse(request.body)).toEqual({ - password: "[REDACTED]", - accessToken: "[REDACTED]", - nested: { account_id: "[REDACTED]", safe: "visible" }, - }) - }) - - test("extends default header redaction and allow lists", () => { - const redactor = HttpRecorderInternal.Redactor.make({ - headers: ["x-custom-token"], - allowRequestHeaders: ["anthropic-version", "x-custom-token"], - }) - - expect( - redactor.request({ - method: "GET", - url: "https://example.test/path", - headers: { - authorization: "Bearer secret", - "content-type": "application/json", - "anthropic-version": "2023-06-01", - "x-custom-token": "secret", - }, - body: "", - }).headers, - ).toEqual({ - "anthropic-version": "2023-06-01", - "content-type": "application/json", - "x-custom-token": "[REDACTED]", - }) - }) - - test("records WebSocket frames in observed client/server order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const response = JSON.stringify({ type: "response.completed", token: "server-secret" }) - let receive: ((message: string | Uint8Array) => Effect.Effect | void) | undefined - const upstream = Socket.make({ - runRaw: (handler, options) => - Effect.gen(function* () { - receive = handler - if (options?.onOpen) yield* options.onOpen - receive = undefined - }), - writer: Effect.succeed(() => - Effect.suspend(() => { - const result = receive?.(response) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }), - ), - }) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw(() => {}, { - onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })), - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/record", - { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - { directory, metadata: { provider: "test" }, mode: "record" }, - ).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), - ), - ), - ) - - expect(JSON.parse(fs.readFileSync(path.join(directory, "websocket/record.json"), "utf8"))).toMatchObject({ - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - events: [ - { direction: "client", kind: "text", body: '{"type":"response.create","token":"[REDACTED]"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed","token":"[REDACTED]"}' }, - ], - }, - ], - }) - }) - - test("WebSocket replay preserves causal frame ordering", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/replay", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: '{"type":"session.created"}' }, - { direction: "client", kind: "text", body: '{"type":"response.create","prompt":"hello"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed"}' }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw((message) => { - if (typeof message !== "string") return - received.push(message) - if (JSON.parse(message).type === "session.created") - return write('{"prompt":"hello","type":"response.create"}') - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/replay", - { url: "wss://example.test/realtime" }, - { directory, compareClientMessagesAsJson: true, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) - }) - - test("the public socket decorator replays a provided Effect socket", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/public-layer", [ - { - transport: "websocket", - open: { url: "", headers: {} }, - events: [ - { direction: "client", kind: "text", body: "hello" }, - { direction: "server", kind: "text", body: "hello" }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runString( - (message) => - Effect.gen(function* () { - received.push(message) - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorder.socket("websocket/public-layer", { directory }).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(["hello"]) - }) - - test("WebSocket replay runs message handlers concurrently", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/concurrent-handlers", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: "first" }, - { direction: "server", kind: "text", body: "second" }, - ], - }, - ]) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const second = yield* Deferred.make() - yield* socket.runString((message) => - message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/concurrent-handlers", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - }) - - test("WebSocket replay rejects close with unconsumed events", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/early-close", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [{ direction: "client", kind: "text", body: "expected" }], - }, - ]) - - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - return yield* Effect.exit(socket.runRaw(() => {}, { onOpen: write(new Socket.CloseEvent(1000)) })) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/early-close", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(failureText(exit)).toContain("closed with unconsumed events") - }) - - test("failed WebSocket runs do not write complete cassettes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - return yield* Effect.exit(socket.runRaw(() => {})) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/failed-run", - { url: "wss://example.test/realtime" }, - { directory, mode: "record" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("connection failed")), - writer: Effect.succeed(() => Effect.void), - }), - ), - ), - ), - ), - ), - ) - - expect(Exit.isFailure(exit)).toBe(true) - expect(fs.existsSync(path.join(directory, "websocket/failed-run.json"))).toBe(false) - }) - - test("WebSocket replay preserves binary frame kinds across reconnects", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const interaction = { - transport: "websocket" as const, - open: { url: "wss://example.test/binary", headers: {} }, - events: [ - { - direction: "client" as const, - kind: "binary" as const, - body: Buffer.from([1, 2]).toString("base64"), - bodyEncoding: "base64" as const, - }, - { - direction: "server" as const, - kind: "binary" as const, - body: Buffer.from([3, 4]).toString("base64"), - bodyEncoding: "base64" as const, - }, - ], - } - await seedCassetteDirectory(directory, "websocket/binary", [interaction, interaction]) - - const received: number[][] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - const run = socket.runRaw( - (message) => { - if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") - received.push([...message]) - }, - { onOpen: write(new Uint8Array([1, 2])) }, - ) - yield* run - yield* run - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/binary", - { url: "wss://example.test/binary" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual([ - [3, 4], - [3, 4], - ]) - }) - - test("replay returns recorded responses in order for identical requests", async () => { - await runWith( - "record-replay/retry", - {}, - Effect.gen(function* () { - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') - }), - ) - }) - - test("replay reports cursor exhaustion when more requests are made than recorded", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - yield* post("https://example.test/echo", { step: 2 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - - test("replay validates each recorded request in order", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("$.step expected 2, received 3") - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - }), - ) - }) - - test("concurrent replay claims each interaction once", async () => { - const results = await runWith( - "record-replay/retry", - {}, - Effect.all( - [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], - { concurrency: "unbounded" }, - ), - ) - - expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) - }) - - test("replays when the cassette exists", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) - await seedCassetteDirectory(directory, "auto-replay", [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/echo", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ step: 1 }), - }, - response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, - }, - ]) - - const result = await runWith("auto-replay", { directory }, post("https://example.test/echo", { step: 1 })) - expect(result).toBe('{"reply":"hi"}') - }) - - test("forces replay when CI=true even if cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) - const previous = process.env.CI - process.env.CI = "true" - try { - const exit = await Effect.runPromise( - Effect.exit( - post("https://example.test/echo", { step: 1 }).pipe( - Effect.provide(HttpRecorder.http("missing-cassette", { directory })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') - } finally { - if (previous === undefined) delete process.env.CI - else process.env.CI = previous - } - }) - - test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { - await run( - Effect.gen(function* () { - const exit = yield* Effect.exit( - post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), - ) - const message = failureText(exit) - expect(message).toContain("url:") - expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") - expect(message).toContain("body:") - expect(message).toContain("$.step expected 1, received 3") - expect(message).toContain('$.token expected undefined, received "[REDACTED]"') - expect(message).not.toContain("sk-123456789012345678901234") - }), - ) - }) - - test("records to disk when the cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-")) - using server = Bun.serve({ - port: 0, - fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }), - }) - const url = `http://127.0.0.1:${server.port}/echo` - // CI=true forces replay; clear it so we exercise the local-dev auto-record path. - const previous = process.env.CI - delete process.env.CI - try { - const result = await runWith("auto-record", { directory }, post(url, { step: 1 })) - expect(result).toBe('{"reply":"recorded"}') - expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records concurrent requests in request-start order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-order-")) - const first = Promise.withResolvers() - const completed: string[] = [] - using server = Bun.serve({ - port: 0, - fetch: async (request) => { - const name = new URL(request.url).pathname.slice(1) - if (name === "first") { - await first.promise - completed.push(name) - return new Response(name) - } - completed.push(name) - first.resolve() - return new Response(name) - }, - }) - const previous = process.env.CI - delete process.env.CI - try { - const request = (name: string) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) - return yield* response.text - }) - const responses = await Effect.runPromise( - Effect.all([request("first"), request("second")], { concurrency: "unbounded" }).pipe( - Effect.provide(HttpRecorder.http("concurrent-order", { directory })), - ), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent-order.json"), "utf8")) - - expect(completed).toEqual(["second", "first"]) - expect(responses).toEqual(["first", "second"]) - expect(cassette.interactions.map((interaction: Interaction) => interaction.request.url)).toEqual([ - `http://127.0.0.1:${server.port}/first`, - `http://127.0.0.1:${server.port}/second`, - ]) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("returns the live response while persisting its redacted snapshot", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-live-response-")) - using server = Bun.serve({ - port: 0, - fetch: () => - new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { - headers: { "content-type": "application/json", "x-request-id": "request-1" }, - }), - }) - const previous = process.env.CI - delete process.env.CI - try { - const body = await runWith( - "live-response", - { directory }, - post(`http://127.0.0.1:${server.port}/response`, { ok: true }), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "live-response.json"), "utf8")) - - expect(body).toBe('{"access_token":"live-secret","safe":true}') - expect(cassette.interactions[0].response.body).toBe('{"access_token":"[REDACTED]","safe":true}') - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("reconstructs responses with null-body statuses", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-no-content-")) - using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) }) - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) - }) - const response = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.http("no-content", { directory }))), - ) - - expect(response.status).toBe(204) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records and replays arbitrary binary responses without changing bytes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-binary-")) - const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) - using server = Bun.serve({ - port: 0, - fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), - }) - const url = `http://127.0.0.1:${server.port}/image.png` - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(url)) - return new Uint8Array(yield* response.arrayBuffer) - }) - const record = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) - await server.stop() - const replay = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "binary.json"), "utf8")) - - expect(record).toEqual(expected) - expect(replay).toEqual(expected) - expect(cassette.interactions[0].response.bodyEncoding).toBe("base64") - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { - using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") }) - const url = `http://127.0.0.1:${server.port}/leaky` - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-")) - - const exit = await Effect.runPromise( - Effect.exit( - post(url, { ok: true }).pipe( - Effect.provide(HttpRecorderInternal.cassetteLayer("unsafe-record", { directory, mode: "record" })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("contains possible secrets") - expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false) - }) - - test("failed memory appends leave cassette state unchanged", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - const interaction: Interaction = { - transport: "http", - request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "safe" }, - } - yield* cassette.append("transactional", interaction) - yield* cassette - .append("transactional", { - ...interaction, - response: { ...interaction.response, body: "Bearer abcdefghijklmnopqrstuvwxyz1234" }, - }) - .pipe(Effect.flip) - - expect(yield* cassette.read("transactional")).toEqual([interaction]) - }).pipe(Effect.provide(HttpRecorderInternal.Cassette.memory())), - ) - }) - - test("concurrent file appends preserve every interaction", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-concurrent-")) - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach( - Array.from({ length: 20 }, (_, index) => index), - (index) => - cassette.append("concurrent", { - transport: "http", - request: { method: "GET", url: `https://example.test/${index}`, headers: {}, body: "" }, - response: { status: 200, headers: {}, body: String(index) }, - }), - { concurrency: "unbounded" }, - ) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent.json"), "utf8")) - expect(cassette.interactions).toHaveLength(20) - expect(fs.readdirSync(directory).filter((file) => file.endsWith(".tmp"))).toEqual([]) - }) - - test("rejects cassette paths outside the recordings directory", () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-path-")) - expect(() => HttpRecorderInternal.hasCassetteSync("../outside", { directory })).toThrow("Invalid cassette name") - expect(() => HttpRecorderInternal.hasCassetteSync("C:\\outside", { directory })).toThrow("Invalid cassette name") - }) - - test("Cassette.list enumerates recorded cassette names", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-")) - await seedCassetteDirectory(directory, "alpha/one", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "a" }, - }, - ]) - await seedCassetteDirectory(directory, "beta", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "b" }, - }, - ]) - - const names = await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - return yield* cassette.list() - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - expect(names).toEqual(["alpha/one", "beta"]) - }) -}) diff --git a/packages/http-recorder/test/redaction.test.ts b/packages/http-recorder/test/redaction.test.ts new file mode 100644 index 0000000000..64e139b01f --- /dev/null +++ b/packages/http-recorder/test/redaction.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test" +import { HttpBody, HttpClientRequest } from "effect/unstable/http" +import { redactedErrorRequest } from "../src/http/recorder" +import { make, redactHeaders, redactUrl } from "../src/redaction/redactor" +import { secretFindings } from "../src/redaction/secrets" + +describe("redaction", () => { + test("redacts sensitive URL query parameters", () => { + expect( + redactUrl( + "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", + ), + ).toBe( + "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", + ) + }) + + test("redacts URL credentials", () => { + expect(redactUrl("https://user:password@example.test/path?safe=value")).toBe( + "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", + ) + }) + + test("applies custom URL redaction after built-in redaction", () => { + expect( + redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) => + url.replace("/accounts/real-account/", "/accounts/{account}/"), + ), + ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") + }) + + test("redacts sensitive headers when allow-listed", () => { + expect( + redactHeaders( + { + authorization: "Bearer secret-token", + "content-type": "application/json", + "x-custom-token": "custom-secret", + "x-api-key": "secret-key", + "x-goog-api-key": "secret-google-key", + }, + ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], + ["x-custom-token"], + ), + ).toEqual({ + authorization: "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + "x-custom-token": "[REDACTED]", + "x-goog-api-key": "[REDACTED]", + }) + }) + + test("redacts error requests without retaining headers, params, or body", () => { + const request = HttpClientRequest.post("https://example.test/path", { + headers: { authorization: "Bearer super-secret" }, + body: HttpBody.text("super-secret-body", "text/plain"), + }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) + + expect(redactedErrorRequest(request).toJSON()).toMatchObject({ + url: "https://example.test/path", + urlParams: { params: [] }, + headers: {}, + body: { _tag: "Empty" }, + }) + }) + + test("detects secret-looking values without returning the secret", () => { + expect( + secretFindings({ + version: 1, + interactions: [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/path?key=sk-123456789012345678901234", + headers: {}, + body: JSON.stringify({ + nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE", + }), + }, + response: { + status: 200, + headers: {}, + body: "Bearer abcdefghijklmnopqrstuvwxyz", + }, + }, + ], + }), + ).toEqual([ + { path: "interactions[0].request.url", reason: "API key" }, + { path: "interactions[0].request.body", reason: "Google API key" }, + { path: "interactions[0].response.body", reason: "bearer token" }, + ]) + }) + + test("detects secret-looking values inside metadata", () => { + expect( + secretFindings({ + version: 1, + metadata: { token: "sk-123456789012345678901234" }, + interactions: [], + }), + ).toEqual([{ path: "metadata.token", reason: "API key" }]) + }) + + test("redacts configured and common sensitive JSON fields", () => { + const redactor = make({ + jsonFields: ["account_id"], + }) + const request = redactor.request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + password: "secret-password", + accessToken: "access-token", + nested: { account_id: "account-123", safe: "visible" }, + }), + }) + + expect(JSON.parse(request.body)).toEqual({ + password: "[REDACTED]", + accessToken: "[REDACTED]", + nested: { account_id: "[REDACTED]", safe: "visible" }, + }) + }) + + test("preserves JSON text when no fields are redacted", () => { + const body = '{\n "id": 9007199254740993,\n "safe": true\n}' + + expect( + make().request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body, + }).body, + ).toBe(body) + }) + + test("extends default header redaction and allow lists", () => { + const redactor = make({ + headers: ["x-custom-token"], + allowRequestHeaders: ["anthropic-version", "x-custom-token"], + }) + + expect( + redactor.request({ + method: "GET", + url: "https://example.test/path", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "x-custom-token": "secret", + }, + body: "", + }).headers, + ).toEqual({ + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-custom-token": "[REDACTED]", + }) + }) +}) diff --git a/packages/http-recorder/test/support.ts b/packages/http-recorder/test/support.ts new file mode 100644 index 0000000000..30fa8205f2 --- /dev/null +++ b/packages/http-recorder/test/support.ts @@ -0,0 +1,61 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Cause, Effect, Exit } from "effect" +import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { decodeCassette, type Interaction } from "../src/cassette/model" +import { Service, fileSystem } from "../src/cassette/store" + +export const tempDirectory = (prefix: string) => { + const directory = mkdtempSync(join(tmpdir(), prefix)) + return { + path: directory, + [Symbol.dispose]() { + rmSync(directory, { recursive: true, force: true }) + }, + } +} + +export const post = (url: string, body: object) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute( + HttpClientRequest.post(url, { + headers: { "content-type": "application/json" }, + body: HttpBody.text(JSON.stringify(body), "application/json"), + }), + ) + return yield* response.text + }) + +export const readCassette = (file: string) => decodeCassette(JSON.parse(readFileSync(file, "utf8"))) + +export const runFileCassette = (directory: string, effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(fileSystem({ directory })), Effect.provide(NodeFileSystem.layer))) + +export const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + runFileCassette( + directory, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }), + ) + +export const withEnvironment = async (name: string, value: string | undefined, run: () => Promise) => { + const previous = process.env[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + try { + return await run() + } finally { + if (previous === undefined) delete process.env[name] + else process.env[name] = previous + } +} + +export const failureText = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "" + return Cause.prettyErrors(exit.cause).join("\n") +} diff --git a/packages/http-recorder/test/tsconfig.json b/packages/http-recorder/test/tsconfig.json new file mode 100644 index 0000000000..97888caa28 --- /dev/null +++ b/packages/http-recorder/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "module": "preserve", + "moduleResolution": "bundler" + }, + "include": ["../src", "."] +} diff --git a/packages/http-recorder/test/websocket.test.ts b/packages/http-recorder/test/websocket.test.ts new file mode 100644 index 0000000000..2d4d30cbf2 --- /dev/null +++ b/packages/http-recorder/test/websocket.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Socket } from "effect/unstable/socket" +import { existsSync } from "node:fs" +import { HttpRecorder } from "../src" +import { layerSocketWithMode } from "../src/websocket/recorder" +import { failureText, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" + +const unavailableSocket = Socket.make({ + runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), + writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), +}) + +class EchoWebSocket extends EventTarget { + readonly protocol = "" + readonly extensions = "" + bufferedAmount = 0 + binaryType: BinaryType = "blob" + readyState = 0 + + constructor(readonly url: string) { + super() + queueMicrotask(() => { + this.readyState = 1 + this.dispatchEvent(new Event("open")) + }) + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { data }))) + } + + close(code = 1000, reason = "") { + if (this.readyState === 3) return + this.readyState = 3 + this.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: code === 1000 })) + } +} + +describe("WebSocket", () => { + test("constructor recording is complete when the recorder layer closes", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-record", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, (url) => new EchoWebSocket(url) as unknown as globalThis.WebSocket), + ), + ) + + await withEnvironment("CI", undefined, () => + Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://echo.example.test/one", { + protocols: ["echo.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + yield* socket.runString(() => write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie), { + onOpen: write("hello").pipe(Effect.orDie), + }) + }).pipe(Effect.scoped, Effect.provide(recorder)), + ), + ) + + expect(readCassette(`${directory.path}/websocket/constructor-record.json`).interactions).toEqual([ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://echo.example.test/one", + protocols: ["echo.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: "hello" }, + { direction: "server", kind: "text", body: "hello" }, + ], + }, + ]) + }) + + test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: ["events.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: '{"type":"subscribe"}' }, + { direction: "server", kind: "text", body: '{"type":"ready"}' }, + ], + }, + ]) + const unavailableConstructor = () => { + throw new Error("unexpected live WebSocket construction") + } + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor", { + directory: directory.path, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, unavailableConstructor))) + + const received = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/one", { + protocols: ["events.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + const received: string[] = [] + yield* socket.runString( + (message) => { + received.push(message) + }, + { + onOpen: write('{"type":"subscribe"}').pipe(Effect.orDie), + }, + ) + return received + }).pipe(Effect.scoped, Effect.provide(recorder)), + ) + + expect(received).toEqual(['{"type":"ready"}']) + }) + + test("constructor replay rejects a different dynamic URL", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor-mismatch", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: [], + close: { code: 1000, reason: "complete" }, + }, + events: [], + }, + ]) + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-mismatch", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, () => { + throw new Error("unexpected live WebSocket construction") + }), + ), + ) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/two") + yield* socket.runString(() => {}) + }).pipe(Effect.scoped, Effect.exit, Effect.provide(recorder)), + ) + + expect(Exit.isFailure(exit)).toBe(true) + }) + + test("records WebSocket frames in observed client/server order", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const response = JSON.stringify({ + type: "response.completed", + token: "server-secret", + }) + const upstream = Socket.make({ + runRaw: (handler, options) => + Effect.gen(function* () { + if (options?.onOpen) yield* options.onOpen + const result = handler(response) + if (Effect.isEffect(result)) yield* result + }), + writer: Effect.succeed(() => Effect.void), + }) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw(() => {}, { + onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie), + }) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/record", { + directory: directory.path, + metadata: { provider: "test" }, + mode: "record", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), + ), + ), + ) + + expect(readCassette(`${directory.path}/websocket/record.json`)).toMatchObject({ + interactions: [ + { + transport: "websocket", + events: [ + { + direction: "client", + kind: "text", + body: '{"type":"response.create","token":"[REDACTED]"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","token":"[REDACTED]"}', + }, + ], + }, + ], + }) + }) + + test("WebSocket replay preserves causal frame ordering", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/replay", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"hello"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw((message) => + Effect.gen(function* () { + if (typeof message !== "string") return + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null || !("type" in event)) return + if (event.type === "session.created") yield* write('{"prompt":"hello","type":"response.create"}') + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/replay", { + directory: directory.path, + compareClientMessagesAsJson: true, + mode: "replay", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket))), + ), + ), + ) + + expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) + }) + + test("the public socket decorator replays a causal provider conversation", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/public-layer", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"first"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"first"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"second"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"second"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runString((message) => + Effect.gen(function* () { + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null) return + if ("type" in event && event.type === "session.created") { + yield* write('{"prompt":"first","type":"response.create"}') + return + } + if ("id" in event && event.id === "first") { + yield* write('{"prompt":"second","type":"response.create"}') + return + } + yield* write(new Socket.CloseEvent(1000, "done")) + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerSocket("websocket/public-layer", { directory: directory.path }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + '{"type":"session.created"}', + '{"type":"response.completed","id":"first"}', + '{"type":"response.completed","id":"second"}', + ]) + }) + + test("WebSocket replay runs message handlers concurrently", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-handlers", [ + { + transport: "websocket", + events: [ + { direction: "server", kind: "text", body: "first" }, + { direction: "server", kind: "text", body: "second" }, + ], + }, + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const second = yield* Deferred.make() + yield* socket.runString((message) => + message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-handlers", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + }) + + test("rejected concurrent replay does not consume the next interaction", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-runs", [ + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "first" }] }, + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "second" }] }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const first = yield* socket + .runString((message) => + Effect.gen(function* () { + received.push(message) + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + + const concurrent = yield* Effect.exit(socket.runString(() => Effect.void)) + expect(failureText(concurrent)).toContain("Concurrent runs") + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(first) + yield* socket.runString((message) => Effect.sync(() => received.push(message))) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-runs", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual(["first", "second"]) + }) + + test("WebSocket replay rejects close with unconsumed events", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/early-close", [ + { + transport: "websocket", + events: [{ direction: "client", kind: "text", body: "expected" }], + }, + ]) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + return yield* Effect.exit( + socket.runRaw(() => {}, { + onOpen: write(new Socket.CloseEvent(1000)).pipe(Effect.orDie), + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/early-close", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(failureText(exit)).toContain("closed with unconsumed events") + }) + + test("failed WebSocket runs do not write complete cassettes", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + return yield* Effect.exit(socket.runRaw(() => {})) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/failed-run", { directory: directory.path, mode: "record" }).pipe( + Layer.provide( + Layer.succeed( + Socket.Socket, + Socket.make({ + runRaw: () => Effect.die(new Error("connection failed")), + writer: Effect.succeed(() => Effect.void), + }), + ), + ), + ), + ), + ), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(existsSync(`${directory.path}/websocket/failed-run.json`)).toBe(false) + }) + + test("WebSocket replay preserves binary frame kinds across reconnects", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const interaction = { + transport: "websocket" as const, + events: [ + { + direction: "client" as const, + kind: "binary" as const, + body: Buffer.from([1, 2]).toString("base64"), + bodyEncoding: "base64" as const, + }, + { + direction: "server" as const, + kind: "binary" as const, + body: Buffer.from([3, 4]).toString("base64"), + bodyEncoding: "base64" as const, + }, + ], + } + await seedCassetteDirectory(directory.path, "websocket/binary", [interaction, interaction]) + + const received: number[][] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + const run = socket.runRaw( + (message) => { + if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") + received.push([...message]) + }, + { onOpen: write(new Uint8Array([1, 2])).pipe(Effect.orDie) }, + ) + yield* run + yield* run + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/binary", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + [3, 4], + [3, 4], + ]) + }) +}) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index f6ad6b02d7..30822e57bb 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1,7 +1,7 @@ import { isAbsolute, join } from "node:path" -import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { Context, Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" import { HttpMethod, type HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { format } from "prettier" export type InputField = { @@ -35,8 +35,6 @@ export type Contract = { readonly groups: ReadonlyArray } -export type EndpointName = string | readonly [string, ...Array] - export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { reason: Schema.String, }) { @@ -89,7 +87,6 @@ export function compile( api: HttpApi.HttpApi, options?: { readonly groupNames?: Readonly> - readonly endpointNames?: Readonly> readonly omitEndpoints?: ReadonlySet }, ): Contract { @@ -151,8 +148,11 @@ export function compile( for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) } - const clientPath = normalizeClientPath( - options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + const clientPath = clientEndpointPath( + group.identifier, + Context.getOrElse(endpoint.annotations, OpenApi.Identifier, () => + group.topLevel ? endpoint.name : `${group.identifier}.${endpoint.name}`, + ), ) endpoints.push({ group: groupName, @@ -699,21 +699,17 @@ function clientOperationKey(group: Group, endpoint: Endpoint) { return [group.identifier, ...endpoint.clientPath].join(".") } -function clientEndpointName(name: string) { - return name.slice(name.lastIndexOf(".") + 1) -} - -function normalizeClientPath(path: EndpointName) { - const result = typeof path === "string" ? [path] : [...path] +function clientEndpointPath(group: string, name: string) { + const parts = name.split(".") + if (parts[0] === "v2") parts.shift() + const index = parts.lastIndexOf(group.slice(group.lastIndexOf(".") + 1)) + const result = index < 0 ? parts : parts.slice(index + 1) if (result.length === 0 || result.some((part) => part.length === 0)) { throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" }) } if (result.some((part) => part === "__proto__")) { throw new GenerationError({ reason: "Client endpoint path cannot contain __proto__" }) } - if (typeof path !== "string" && result.some((part) => part.includes("."))) { - throw new GenerationError({ reason: "Nested client endpoint path segments cannot contain dots" }) - } return result as [string, ...Array] } diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index f6a55e73a0..f949f70d46 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { format } from "prettier" import { compile as compileContract, @@ -139,48 +139,50 @@ describe("HttpApiCodegen.generate", () => { expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) }) - test("supports explicit public endpoint names", () => { + test("derives nested paths from OpenAPI operation IDs", () => { const source = HttpApi.make("test").add( - HttpApiGroup.make("server.permission") - .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) - .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.stage" }), + ), + ), ) - const contract = compileContract(source, { - endpointNames: { "permission.request.list": "listRequests" }, - }) + const contract = compileContract(source, { groupNames: { "server.session": "session" } }) - expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"]) + expect(contract.groups[0]?.endpoints[0]?.clientPath).toEqual(["revert", "stage"]) + expect(OpenApi.fromApi(source).paths["/session/revert/stage"]?.get?.operationId).toBe("v2.session.revert.stage") }) - test("supports explicit nested endpoint paths while string aliases remain flat", () => { + test("uses nested OpenAPI operation IDs across emitters", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("server.session") - .add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })), + .add( + HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.put" }), + ), + ) + .add( + HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.remove" }), + ), + ), ) - const contract = compileContract(source, { - groupNames: { "server.session": "session" }, - endpointNames: { - "session.instructions.list": ["instructions", "list"], - "session.instructions.put": ["instructions", "put"], - "session.instructions.remove": ["instructions", "remove"], - "session.messages": "instructions.flat", - }, - }) + const contract = compileContract(source, { groupNames: { "server.session": "session" } }) expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([ ["instructions", "list"], ["instructions", "put"], ["instructions", "remove"], - ["instructions.flat"], ]) expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([ "instructions.list", "instructions.put", "instructions.remove", - "instructions.flat", ]) const promise = emitPromise(contract, { @@ -196,7 +198,6 @@ describe("HttpApiCodegen.generate", () => { expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)') - expect(promiseClient).toContain('"instructions.flat": (requestOptions?: RequestOptions)') expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"') expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire") expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string") @@ -204,12 +205,12 @@ describe("HttpApiCodegen.generate", () => { const effect = emitEffect(contract) expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( - '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }, "instructions.flat": Endpoint3(raw)', + '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }', ) const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" }) expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain( - '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }, "instructions.flat": Endpoint0_3(raw)', + '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }', ) const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" }) @@ -217,25 +218,28 @@ describe("HttpApiCodegen.generate", () => { expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation') expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation') expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation') - expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation') }) - test("executes nested Promise endpoint aliases", async () => { + test("executes nested Promise operation IDs", async () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })), - ) - const output = emitPromise( - compileContract(source, { - endpointNames: { - "instructions.list": ["instructions", "list"], - "instructions.put": ["instructions", "put"], - "instructions.remove": ["instructions", "remove"], - }, - }), + .add( + HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.put" }), + ), + ) + .add( + HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.remove" }), + ), + ), ) + const output = emitPromise(compileContract(source)) const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) const methods: Array = [] @@ -262,59 +266,67 @@ describe("HttpApiCodegen.generate", () => { test("rejects duplicate and leaf-namespace endpoint paths", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) - .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), + .add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ), ) - expect(() => - compileContract(source, { endpointNames: { first: ["instructions", "list"], second: ["instructions", "list"] } }), - ).toThrow("Client endpoint name collision: session.instructions.list") - expect(() => - compileContract(source, { endpointNames: { first: "instructions", second: ["instructions", "list"] } }), - ).toThrow("Client endpoint name collision: session.instructions.list") + expect(() => compileContract(source)).toThrow("Client endpoint name collision: session.instructions.list") }) test("rejects nested root collisions across top-level groups", () => { const source = HttpApi.make("test") .add( HttpApiGroup.make("first", { topLevel: true }).add( - HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }), + HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "instructions.list" }), + ), ), ) .add( HttpApiGroup.make("second", { topLevel: true }).add( - HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }), + HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "instructions.put" }), + ), ), ) - expect(() => - compileContract(source, { - endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] }, - }), - ).toThrow("Client name collision: instructions") + expect(() => compileContract(source)).toThrow("Client name collision: instructions") }) test("rejects nested paths that collide after type-name normalization", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) - .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), + .add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.foo.bar" }), + ), + ) + .add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.foo-bar" }), + ), + ), ) - expect(() => compileContract(source, { endpointNames: { first: ["foo", "bar"], second: "foo-bar" } })).toThrow( - "Client endpoint type collision: SessionFooBar", - ) + expect(() => compileContract(source)).toThrow("Client endpoint type collision: SessionFooBar") }) test("rejects ambiguous and prototype-mutating nested path segments", () => { - const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String })) + const source = api( + HttpApiEndpoint.get("get", "/session", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.__proto__.get" }), + ), + ) - expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow( - "Nested client endpoint path segments cannot contain dots", - ) - expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow( - "Client endpoint path cannot contain __proto__", - ) + expect(() => compileContract(source)).toThrow("Client endpoint path cannot contain __proto__") }) test("rejects normalized group, operation-key, and group prototype collisions", () => { @@ -324,20 +336,38 @@ describe("HttpApiCodegen.generate", () => { expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar") const endpointType = HttpApi.make("test") - .add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) - .add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))) - expect(() => - compileContract(endpointType, { - endpointNames: { first: ["bar", "baz"], second: ["baz"] }, - }), - ).toThrow("Client endpoint type collision: FooBarBaz") + .add( + HttpApiGroup.make("foo").add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "foo.bar.baz" }), + ), + ), + ) + .add( + HttpApiGroup.make("fooBar").add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "fooBar.baz" }), + ), + ), + ) + expect(() => compileContract(endpointType)).toThrow("Client endpoint type collision: FooBarBaz") const operationKey = HttpApi.make("test") - .add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String }))) - .add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }))) - expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow( - "Client operation key collision: a.b.c", - ) + .add( + HttpApiGroup.make("a.b").add( + HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "a.b.c" }), + ), + ), + ) + .add( + HttpApiGroup.make("a").add( + HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "a.b.c" }), + ), + ), + ) + expect(() => compileContract(operationKey)).toThrow("Client operation key collision: a.b.c") const prototype = HttpApi.make("test").add( HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })), diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index c4edaba2b4..e883a9e47d 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -113,6 +113,23 @@ Keep provider facades small and explicit: `Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior. +### Provider Package Entrypoints + +Catalog-selected native providers use package-like export paths from `@opencode-ai/llm`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays. + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +const selected = model("gpt-5", { + apiKey, + transport: "websocket", +}) +``` + +Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`. + +Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. + ### Folder layout ``` @@ -303,7 +320,7 @@ recorded.effect("streams text", () => ) ``` -Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. +Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file: @@ -316,6 +333,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re **Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip. -**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. +**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/README.md b/packages/llm/README.md index 330222de93..477d01701a 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -106,6 +106,32 @@ const gateway = CloudflareAIGateway.configure({ Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. +### Package-like entrypoints + +Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/llm` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +const selected = model("gpt-5", { + apiKey: process.env.OPENAI_API_KEY, + transport: "websocket", + headers: { "x-application": "opencode" }, + limits: { context: 200_000, output: 64_000 }, +}) +``` + +OpenAI Chat and OpenAI Responses are separate semantic entrypoints: + +- `@opencode-ai/llm/providers/openai/chat` +- `@opencode-ai/llm/providers/openai/responses` + +Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. + +Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. + +Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package. + ## Provider options & HTTP overlays Three escape hatches in order of stability: diff --git a/packages/llm/STATUS.md b/packages/llm/STATUS.md index 874862d9af..872023e665 100644 --- a/packages/llm/STATUS.md +++ b/packages/llm/STATUS.md @@ -1,6 +1,6 @@ # LLM Provider Parity Status -Last reviewed: 2026-07-02 +Last reviewed: 2026-07-08 This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. @@ -64,26 +64,27 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries need to be made explicit in docs and exports. Protocol namespaces exist, but planned public groupings should call out OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat, OpenAI-compatible Responses, Anthropic Messages, Gemini, Vertex Gemini, Vertex Anthropic Messages, Bedrock Converse, and Bedrock Mantle as separate API slices. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. -## Proposed Native Namespace Shape +## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| Namespace | Purpose | -| --- | --- | -| `OpenAI.Chat` or `OpenAIChat` | OpenAI `/chat/completions` semantics. | -| `OpenAI.Responses` or `OpenAIResponses` | OpenAI `/responses` HTTP and WebSocket semantics. | -| `OpenAICompatible.Chat` or `OpenAICompatibleChat` | Generic OpenAI-compatible `/chat/completions`. | -| `OpenAICompatible.Responses` or `OpenAICompatibleResponses` | Generic OpenAI-compatible `/responses`. Missing today. | -| `Anthropic.Messages` or `AnthropicMessages` | Anthropic Messages API. | -| `Google.Gemini` or `Gemini` | Gemini Developer API. | -| `GoogleVertex.Gemini` | Vertex Gemini API. Missing today. | -| `GoogleVertex.AnthropicMessages` | Vertex-hosted Anthropic Messages API. Missing today. | -| `Bedrock.Converse` or `BedrockConverse` | AWS Bedrock Converse API. | -| `Bedrock.Mantle` | AWS Bedrock Mantle OpenAI-compatible APIs. Missing today. | -| `Azure.OpenAIChat` / `Azure.OpenAIResponses` | Azure deployment specializations over OpenAI protocols. | +| API slice | Package-like entrypoint | Purpose | +| --- | --- | --- | +| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. | +| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | Missing | Vertex Gemini API. | +| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | +| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices diff --git a/packages/llm/example/call-sites.md b/packages/llm/example/call-sites.md index 093f74e51d..7c5a411cba 100644 --- a/packages/llm/example/call-sites.md +++ b/packages/llm/example/call-sites.md @@ -342,14 +342,24 @@ const response = ) ``` -HTTP versus WebSocket is represented as named route selectors, not as model or -request overrides. Same protocol, different transport, different route: +For direct provider-facade calls, HTTP versus WebSocket is represented as named +route selectors, not as model or request overrides. Same protocol, different +transport, different route: ```ts OpenAI.responses("gpt-4o") OpenAI.responsesWebSocket("gpt-4o") ``` +The package-like OpenAI Responses entrypoint instead keeps transport scoped to +Responses settings while preserving the same `model(...)` contract: + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +model("gpt-4o", { apiKey, transport: "websocket" }) +``` + The client should not require a different public layer just because a selected route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime capabilities available; routes that do not need WebSocket simply never touch it. @@ -468,10 +478,10 @@ const model = ``` That boundary can branch on durable config/catalog metadata and call typed -provider APIs directly. Transport selection belongs there too: map metadata like -`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use -the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes -the route carried by the model. +provider APIs directly. A direct provider-facade boundary maps metadata like +`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading +boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint. +The client runtime only executes the route carried by the resulting model. ## Competitive Shape @@ -507,8 +517,9 @@ App boundary = explicit durable-config -> typed-provider call id. - No `model(id, overrides)` escape hatch. Model selection takes the model id; endpoint/auth/deployment customization happens by configuring the route first. -- No transport override on model/request. HTTP SSE versus WebSocket is a named - route selector such as `responses` versus `responsesWebSocket`. +- No transport override on an executable model or request. Direct provider + facades use `responses` versus `responsesWebSocket`; the package-like Responses + entrypoint maps its scoped `transport` setting before constructing the model. - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one client layer with the available transport capabilities. - No executable `ModelRef`. The executable handle is `Model`; durable model diff --git a/packages/llm/package.json b/packages/llm/package.json index 783ca64b95..1947cbb8d7 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -19,6 +19,8 @@ "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", + "./providers/azure/responses": "./src/providers/azure/responses.ts", + "./providers/azure/chat": "./src/providers/azure/chat.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index f626827292..6117cd5c20 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -876,6 +876,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "anthropic", + providerMetadataKey: "anthropic", protocol, endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 0c6b0598ff..4984e32365 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -657,6 +657,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "bedrock", + providerMetadataKey: "bedrock", protocol, // Bedrock's URL embeds the region in the route endpoint host and the // validated modelId in the path. We read the validated body so the URL diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c4bb9476a4..82a69b059e 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -500,6 +500,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "google", + providerMetadataKey: "google", protocol, // Gemini's path embeds the model id and pins SSE framing at the URL level. endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, { diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index eb3b7141c6..cba67ce0a9 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -493,6 +493,7 @@ export const httpTransport = HttpTransport.sseJson.with() export const route = Route.make({ id: ADAPTER, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts index ce3f0a83d7..9ae9a53b43 100644 --- a/packages/llm/src/protocols/openai-compatible-chat.ts +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -16,6 +16,7 @@ export type OpenAICompatibleChatModelInput = RouteRoutedModelInput */ export const route = Route.make({ id: ADAPTER, + providerMetadataKey: "openai", protocol: OpenAIChat.protocol, endpoint: Endpoint.path("/chat/completions"), framing: Framing.sse, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index de18bf42a0..e2889d5b6d 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -619,6 +619,11 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste ] } +const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events] +} + const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] const events: LLMEvent[] = [] @@ -810,6 +815,8 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const item = event.item if (!item) return [state, NO_EVENTS] satisfies StepResult + if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id }) + if (item.type === "function_call") { if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult const tools = state.tools[item.id] @@ -920,6 +927,7 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) + if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) if ( event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta" || @@ -982,6 +990,7 @@ export const httpTransport = HttpTransport.sseJson.with() export const route = Route.make({ id: ADAPTER, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint, auth, @@ -1010,6 +1019,7 @@ export const webSocketTransport = WebSocketTransport.jsonTransport.with< export const webSocketRoute = Route.make({ id: `${ADAPTER}-websocket`, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint, auth, diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index 8d3f2a8f63..321bd7927e 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -6,6 +6,7 @@ const patterns = [ /input is too long for requested model/i, /exceeds the context window/i, /input token count.*exceeds the maximum/i, + /tokens in request more than max tokens allowed/i, /maximum prompt length is \d+/i, /reduce the length of the messages/i, /maximum context length is \d+ tokens/i, diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts index bfac2d1cad..dd0691a539 100644 --- a/packages/llm/src/providers/azure.ts +++ b/packages/llm/src/providers/azure.ts @@ -1,6 +1,7 @@ import { Auth } from "../route/auth" import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -23,6 +24,14 @@ export type ModelOptions = AzureURL & } export type Config = ModelOptions +export type Settings = ProviderPackage.Settings & + AzureURL & { + readonly apiKey?: string + readonly apiVersion?: string + readonly queryParams?: Readonly> + readonly providerOptions?: OpenAIProviderOptionsInput + } + const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` const responsesRoute = OpenAIResponses.route.with({ @@ -108,3 +117,24 @@ export const provider = { id, configure, } + +const config = (settings: Settings): Config => { + const common = { + apiKey: settings.apiKey, + apiVersion: settings.apiVersion, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + } + if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } + if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } + throw new Error("Azure requires resourceName or baseURL") +} + +export const responsesModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).responses(modelID) +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) +export const model = responsesModel diff --git a/packages/llm/src/providers/azure/chat.ts b/packages/llm/src/providers/azure/chat.ts new file mode 100644 index 0000000000..ff3e474332 --- /dev/null +++ b/packages/llm/src/providers/azure/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../azure" +export type { Settings } from "../azure" diff --git a/packages/llm/src/providers/azure/responses.ts b/packages/llm/src/providers/azure/responses.ts new file mode 100644 index 0000000000..e7b8ab15ae --- /dev/null +++ b/packages/llm/src/providers/azure/responses.ts @@ -0,0 +1,2 @@ +export { responsesModel as model } from "../azure" +export type { Settings } from "../azure" diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts index c8a72c31f6..6cf9ac21ec 100644 --- a/packages/llm/src/providers/google.ts +++ b/packages/llm/src/providers/google.ts @@ -1,7 +1,8 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" -import { ProviderID, type ModelID } from "../schema" +import type { ProviderPackage } from "../provider-package" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as Gemini from "../protocols/gemini" export const id = ProviderID.make("google") @@ -10,6 +11,12 @@ export const routes = [Gemini.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: ProviderOptions +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +39,12 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + }).model(modelID) diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index d3b41f5817..a258aae944 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -36,6 +36,8 @@ export interface RouteBody { export interface Route { readonly id: string readonly provider?: ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string readonly protocol: ProtocolID readonly endpoint: Endpoint readonly auth: AuthDef @@ -184,6 +186,8 @@ export interface MakeInput { readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ @@ -203,6 +207,8 @@ export interface MakeTransportInput { readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ @@ -248,6 +254,7 @@ function makeFromTransport( const route: Route = { id: routeInput.id, provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), + providerMetadataKey: routeInput.providerMetadataKey, protocol: protocol.id, endpoint: routeInput.endpoint, auth: routeInput.auth ?? Auth.none, @@ -329,6 +336,7 @@ export function make( return makeFromTransport({ id: input.id, provider: input.provider, + providerMetadataKey: input.providerMetadataKey, protocol, endpoint: input.endpoint, auth: input.auth, diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 279a3097e1..4775caf2ef 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { ProviderMetadata } from "@opencode-ai/schema/llm" +import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm" export { ProviderMetadata } @@ -36,7 +36,7 @@ export type TextVerbosity = Schema.Schema.Type export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"]) export type MessageRole = Schema.Schema.Type -export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export const FinishReason = LLM.FinishReason export type FinishReason = Schema.Schema.Type export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts new file mode 100644 index 0000000000..3622c89454 --- /dev/null +++ b/packages/llm/test/provider-error.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test" +import { isContextOverflow } from "../src" + +describe("provider error classification", () => { + test("classifies Z.AI GLM token limit messages as context overflow", () => { + expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true) + }) +}) diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index 0d3ddc811c..346b6b86ee 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -10,10 +10,15 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/anthropic"), import("@opencode-ai/llm/providers/openai-compatible"), import("@opencode-ai/llm/providers/amazon-bedrock"), + import("@opencode-ai/llm/providers/azure"), + import("@opencode-ai/llm/providers/azure/responses"), + import("@opencode-ai/llm/providers/azure/chat"), + import("@opencode-ai/llm/providers/google"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) + expect(modules[6].model).toBe(modules[7].model) }) test("maps package settings onto the executable model", () => { @@ -49,4 +54,49 @@ describe("provider package entrypoints", () => { "OpenAI-Project": "proj_123", }) }) + + test("selects Azure API entrypoints with the same model contract", async () => { + const Azure = await import("@opencode-ai/llm/providers/azure") + const AzureChat = await import("@opencode-ai/llm/providers/azure/chat") + const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses") + const settings = { + apiKey: "fixture", + resourceName: "opencode-test", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + } + + const responses = AzureResponses.model("deployment", settings) + const chat = AzureChat.model("deployment", settings) + + expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses") + expect(responses.route.id).toBe("azure-openai-responses") + expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") + expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(chat.route.id).toBe("azure-openai-chat") + }) + + test("maps Google package settings onto the Gemini model", async () => { + const Google = await import("@opencode-ai/llm/providers/google") + const selected = Google.model("gemini-2.5-flash", { + apiKey: "fixture", + baseURL: "https://generativelanguage.test/v1beta", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, + }) + + expect(selected.route.id).toBe("gemini") + expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) + expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) + expect(selected.route.defaults.providerOptions).toEqual({ + gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, + }) + }) }) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index ef67c866d8..ae09cf7120 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -12,7 +12,6 @@ const openAI = OpenAI.configure({ }) const openAIChat = openAI.chat("gpt-4o-mini") const openAIResponses = openAI.responses("gpt-5.5") -const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini") const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", }) @@ -89,14 +88,6 @@ describeRecordedGoldenScenarios([ { id: "image-tool-result", temperature: false, maxTokens: 40 }, ], }, - { - name: "OpenAI Responses WebSocket gpt-4.1-mini", - prefix: "openai-responses-websocket", - model: openAIResponsesWebSocket, - transport: "websocket", - requires: ["OPENAI_API_KEY"], - scenarios: ["tool-loop"], - }, { name: "Anthropic Haiku 4.5", prefix: "anthropic-messages", diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index fbc5f2a864..3d048d4eee 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -764,6 +764,35 @@ describe("OpenAI Responses route", () => { }), ) + // OpenAI's documented stream orders output text within one message item; no + // provider-valid same-kind overlap is evidenced, so done boundaries close it. + it.effect("closes sequential output messages before starting the next", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "First" }, + { type: "response.output_text.done", item_id: "msg_1" }, + { type: "response.output_text.delta", item_id: "msg_2", delta: "Second" }, + { type: "response.output_item.done", item: { type: "message", id: "msg_2" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ + { type: "text-start", id: "msg_1" }, + { type: "text-delta", id: "msg_1", text: "First" }, + { type: "text-end", id: "msg_1" }, + { type: "text-start", id: "msg_2" }, + { type: "text-delta", id: "msg_2", text: "Second" }, + { type: "text-end", id: "msg_2" }, + ]) + }), + ) + it.effect("parses reasoning summary stream fixtures", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts index 540662b299..404a5028b2 100644 --- a/packages/llm/test/recorded-golden.ts +++ b/packages/llm/test/recorded-golden.ts @@ -28,7 +28,7 @@ type TargetInput = { readonly transport?: Transport readonly prefix?: string readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata readonly options?: HttpRecorder.RecorderOptions readonly scenarios: ReadonlyArray } @@ -43,7 +43,7 @@ const defaultPrefix = (target: TargetInput) => { const metadata = (target: TargetInput) => ({ provider: target.model.provider, - protocol: target.protocol, + ...(target.protocol ? { protocol: target.protocol } : {}), route: target.model.route.id, transport: target.transport ?? "http", model: target.model.id, diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts index 97d9b03f54..904a9366ac 100644 --- a/packages/llm/test/recorded-runner.ts +++ b/packages/llm/test/recorded-runner.ts @@ -1,3 +1,4 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" import { test, type TestOptions } from "bun:test" import { Effect, type Layer } from "effect" import { testEffect } from "./lib/effect" @@ -11,7 +12,7 @@ export type RecordedGroupOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export type RecordedCaseOptions = { @@ -21,7 +22,7 @@ export type RecordedCaseOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export const recordedEffectGroup = < @@ -36,7 +37,7 @@ export const recordedEffectGroup = < readonly layer: (input: { readonly cassette: string readonly tags: ReadonlyArray - readonly metadata: Record + readonly metadata: HttpRecorder.CassetteMetadata readonly recording: boolean readonly options: Options readonly caseOptions: CaseOptions diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 669b8de5c5..da40085779 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -1,11 +1,8 @@ -import { NodeFileSystem } from "@effect/platform-node" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import * as path from "node:path" import { fileURLToPath } from "node:url" -import { LLMClient, RequestExecutor } from "../src/route" +import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route" import type { Service as LLMClientService } from "../src/route/client" import type { Service as RequestExecutorService } from "../src/route/executor" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" @@ -14,7 +11,6 @@ import { type RecordedCaseOptions as RunnerCaseOptions, type RecordedGroupOptions, } from "./recorded-runner" -import { webSocketCassetteLayer } from "./recorded-websocket" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") @@ -64,31 +60,27 @@ export const recordedTests = (options: RecordedTestsOptions) => recordedEffectGroup({ duplicateLabel: "recorded cassette", options, - cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), layer: ({ cassette, metadata, options, caseOptions, recording }) => { const recorderOptions = mergeOptions(options.options, caseOptions.options) const recorderMetadata = { ...recorderOptions?.metadata, ...metadata, } - const mode = recording ? "record" : "replay" - const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( - Layer.provide(NodeFileSystem.layer), - ) + if (recording) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR }) + } const requestExecutor = RequestExecutor.layer.pipe( Layer.provide( - HttpRecorderInternal.recordingLayer(cassette, { - mode, + HttpRecorder.layerFetch(cassette, { + ...recorderOptions, + directory: FIXTURES_DIR, metadata: recorderMetadata, - redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact), - match: recorderOptions?.match, - }).pipe(Layer.provide(FetchHttpClient.layer)), + }), ), ) - const deps = Layer.mergeAll( - requestExecutor, - webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), - ) - return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService)) + const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer) + return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))) }, }) diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts deleted file mode 100644 index afeee09b77..0000000000 --- a/packages/llm/test/recorded-websocket.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" -import { Effect, Layer } from "effect" -import { WebSocketExecutor } from "../src/route" -import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" - -const liveWebSocket = WebSocketExecutor.open - -export const webSocketCassetteLayer = ( - cassette: string, - input: { readonly metadata?: Record; readonly mode: HttpRecorderInternal.RecordReplayMode }, -): Layer.Layer => - Layer.effect( - WebSocketExecutor.Service, - Effect.gen(function* () { - const cassetteService = yield* HttpRecorderInternal.Cassette.Service - const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({ - name: cassette, - mode: input.mode, - metadata: input.metadata, - cassette: cassetteService, - live: { open: liveWebSocket }, - compareClientMessagesAsJson: true, - }) - return WebSocketExecutor.Service.of(executor) - }), - ) diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md index c7631cf4cb..f09685b26a 100644 --- a/packages/opencode/specs/simulation/simulated-network-llm.md +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -65,7 +65,7 @@ Exchange = { id, body, queue: Queue, deferred lifecycle } ### 4. Backend control WebSocket (simulation-gated) -Started when the simulation module loads (lazy import, `OPENCODE_SIMULATION` only): a loopback JSON-RPC 2.0 WebSocket on `127.0.0.1:40950+`, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. +Started when `OPENCODE_DRIVE` names a registry manifest: a loopback JSON-RPC 2.0 WebSocket at that manifest's exact backend endpoint, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): @@ -101,8 +101,8 @@ Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not ye A driver manages two loopback WebSocket connections: -- TUI control server (`127.0.0.1:40900+`) — UI state, actions, render, trace. -- Backend control server (`127.0.0.1:40950+`) — LLM exchanges, network log. +- TUI control server (manifest `endpoints.ui`) — UI state, actions, render, trace. +- Backend control server (manifest `endpoints.backend`) — LLM exchanges, network log. Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. @@ -117,7 +117,7 @@ The driver-facing model must be selectable in the TUI. Simulation seeds config ( ## End-to-end flow ``` -driver TUI sim server (40900+) backend + control WS (40950+) +driver TUI drive server backend + drive WS | | | |-- ui.action (submit) ----->| | | |-- (normal app HTTP) ---->| session runner starts diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index ebc4bd3743..c8a470a449 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -12,11 +12,11 @@ This phase proves the core shape without swapping every foundational layer yet. Implementation checklist: -- [x] Add `OPENCODE_SIMULATION=1` activation in V1/full-TUI startup. +- [x] Add `OPENCODE_DRIVE=` activation in V1/full-TUI startup. - [x] Add simulation trace service with in-memory append-only records. - [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. - [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. -- [x] Add reusable JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- [x] Add reusable JSON-RPC WebSocket server at the manifest's UI endpoint. - [x] Expose `ui.state`, `ui.action`, `ui.render`. - [x] Expose `trace.list`, `trace.clear`, `trace.export`. - [x] Wire visible V1/full-TUI renderer path through the same action protocol. @@ -24,8 +24,8 @@ Implementation checklist: Scope: -- Add `OPENCODE_SIMULATION=1` activation. -- Start a TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- Add `OPENCODE_DRIVE=` activation. +- Start a TUI-owned JSON-RPC WebSocket server at the manifest's UI endpoint. - Expose `ui.state`, `ui.action`, `ui.render`. - Use the old simulation action model: type text, press keys, press enter, arrows, focus, click. - Support fake OpenTUI renderer and visible renderer through the same action protocol. @@ -34,7 +34,7 @@ Scope: Done when: -- `OPENCODE_SIMULATION=1 bun run dev` starts the normal app. +- `OPENCODE_DRIVE= bun run dev` starts the normal app and UI drive server. - A local driver can connect to the WebSocket. - The driver can inspect current screen/elements/actions. - The driver can execute real TUI inputs. @@ -54,19 +54,19 @@ Goal: make the app safe and controlled by swapping the lowest layers, not app lo Implementation checklist: - [x] Add `packages/simulation/src/backend` as the home for backend simulation layer replacements, exported from `backend/index.ts` as `simulationReplacements`; `@opencode-ai/simulation` is private/non-published and depends on logic/framework packages (`core`, `llm`, `effect`, OpenTUI), while `server` and `tui` consume it. -- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATION`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. +- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATE`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. - [x] Implement in-memory `FileSystem.FileSystem` (`simulation/filesystem.ts`) replacing the `NodeFileSystem` platform node. Backed by a flat path map; implements the operations the app uses (stat, access, chmod, realPath, read/write file, make/read directory, remove, rename, copy, copyFile, temp dirs, read-only open handles); unused operations die with a clear defect; `watch` fails as unsupported. -- [x] Root the fake filesystem at `OPENCODE_SIMULATION_ROOT` (falling back to `process.cwd()` at layer-build time). The anchor is a real, empty host directory the runner creates and cds into. +- [x] Root the fake filesystem at `process.cwd()` at layer-build time. The anchor is a real, empty host directory the runner creates and cds into. - [x] Deny host filesystem escapes loudly: content/mutation operations outside the root fail with `PermissionDenied` simulation errors. Probe operations (`stat`/`access`/`exists`) report `NotFound` outside the root so walk-up loops (project discovery, `findUp`, `globUp`) terminate naturally. - [x] Add `SimulationFSUtil` replacement (`simulation/fs-util.ts`): wraps the real `FSUtil` layer and reroutes `readDirectoryEntries`, `glob`, and `globUp` — which bypass the injected `FileSystem` via node `fs/promises` and the `glob` package — through the simulated filesystem. - [x] Fix `LayerNode.hoist` conflict detection to compare node implementations instead of object identity; replacement rewriting produces dependency-rewritten copies of the same node, which previously false-positived as "conflicting implementations". -- [x] Add snapshot seeding from `OPENCODE_SIMULATION_STATE`: `project/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. -- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATION=1` + `OPENCODE_SIMULATION_ROOT` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. -- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATION_ROOT/STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). +- [x] Add snapshot seeding from `OPENCODE_SIMULATE_STATE`: `files/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. +- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATE=1` + `OPENCODE_SIMULATE_STATE` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. +- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATE_STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). - [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory). - [x] Add simulated network registry (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves all outbound HTTP against an in-memory route table, denies unknown destinations loudly, and keeps a bounded request log (design: `simulated-network-llm.md`). - [x] Add driver-answered LLM as an OpenAI route in the simulated network (`openai.ts` + `llm-exchange.ts`): provider requests open exchanges; the driver streams chunks back which are encoded as real OpenAI Chat SSE (schema-checked against `OpenAIChatEvent`) and consumed by the real protocol pipeline. No enqueue store — the driver is the model. -- [x] Add backend-hosted simulation control WebSocket (`control.ts`): JSON-RPC on `127.0.0.1:40950+`, started when the simulation module loads. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage two sockets: TUI control (40900+) for UI, backend control (40950+) for LLM/network. +- [x] Add backend-hosted drive control WebSocket (`control.ts`): JSON-RPC at the named manifest's backend endpoint, started when `OPENCODE_DRIVE` is set. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage the manifest's UI endpoint for UI control and backend endpoint for LLM/network control. - [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route). - [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals. - [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`). @@ -78,7 +78,7 @@ Scope: - Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. - Create a real, empty anchor directory (`mkdtemp`) and `process.chdir` into it before any command resolves its working directory; skip creation when the runner already spawned the app inside an anchor. - Root the in-memory filesystem at `process.cwd()` (the anchor). No cwd monkey-patching: cwd, `$PWD`, and `path.resolve()` stay truthful. -- Add snapshot loading from `OPENCODE_SIMULATION_STATE`: read the snapshot directory once at startup and seed the in-memory filesystem (snapshot `project/` paths joined onto the anchor root), config, env, and optional LLM/network state from it. +- Add snapshot loading from `OPENCODE_SIMULATE_STATE`: read the snapshot directory once at startup and seed the in-memory filesystem (snapshot `files/` paths joined onto the anchor root), config, env, and optional LLM/network state from it. - Route config/data/state/cache/temp paths into the simulated space using existing env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`), set before `packages/core/src/global.ts` import-time path setup runs. - Deny host filesystem escapes loudly (paths outside the anchor root fail with typed simulation errors). - Assert the anchor directory on the host is still empty at the end of the run; anything written there means a code path bypassed the simulated filesystem. @@ -97,7 +97,7 @@ Done when: - Unknown network fails with a simulation error. - Host filesystem escape fails with a simulation error. - The anchor directory on the host is empty after a run. -- The app boots from a snapshot directory via `OPENCODE_SIMULATION_STATE` and observes the seeded project files, config, and env through normal app paths. +- The app boots from a snapshot directory via `OPENCODE_SIMULATE_STATE` and observes the seeded project files, config, and env through normal app paths. - A driver can seed a project filesystem. - A driver can enqueue an LLM script and submit a prompt through the TUI. - The real session/tool path consumes the scripted LLM behavior. diff --git a/packages/opencode/specs/simulation/simulation.md b/packages/opencode/specs/simulation/simulation.md index 5478796e78..7682fad0c4 100644 --- a/packages/opencode/specs/simulation/simulation.md +++ b/packages/opencode/specs/simulation/simulation.md @@ -11,7 +11,7 @@ The first milestone is an interactive exploration and model-based testing enviro This is not intended to be a custom simulated app or a separate `simulate` command. The normal app should run, with simulation enabled by one required flag: ```sh -OPENCODE_SIMULATION=1 bun run dev +OPENCODE_SIMULATE=1 bun run dev ``` ## Non-Goals @@ -21,7 +21,7 @@ OPENCODE_SIMULATION=1 bun run dev - Do not build shrinking in the first milestone. - Do not make generated randomized runs part of CI yet. - Do not build differential testing in the first milestone. -- Do not expose simulation controls when `OPENCODE_SIMULATION` is not set. +- Do not expose drive controls when `OPENCODE_DRIVE` is not set. ## Design Principles @@ -37,12 +37,12 @@ OPENCODE_SIMULATION=1 bun run dev ## Activation -`OPENCODE_SIMULATION=1` is the only required flag. +`OPENCODE_SIMULATE=1` swaps the backend's foundational layers for simulated implementations. `OPENCODE_DRIVE=` independently starts the frontend and backend control WebSockets using the exact endpoints from the named opencode-drive registry manifest. `OPENCODE_DRIVE=1` starts an unnamed instance at `ws://127.0.0.1:40900` for the UI and `ws://127.0.0.1:40950` for the backend. Initial state is provided through an optional snapshot directory: ```sh -OPENCODE_SIMULATION=1 OPENCODE_SIMULATION_STATE=/path/to/snapshot bun run dev +OPENCODE_SIMULATE=1 OPENCODE_DRIVE=demo OPENCODE_SIMULATE_STATE=/path/to/snapshot bun run dev ``` Optional flags can be added later, but should stay minimal. Reasonable optional parameters later include renderer mode, trace output path, seed, or port override. @@ -54,15 +54,15 @@ When enabled: - The app creates and changes into a real, empty anchor directory (see Filesystem). - The app reads the snapshot directory, if provided, and seeds all simulated state from it. - The app builds with simulation layer replacements. -- The TUI process starts a loopback WebSocket control server. +- The TUI and backend processes start loopback WebSocket control servers when `OPENCODE_DRIVE` is set. - Simulation-gated backend control routes become available only to the frontend/control path. - In-memory trace recording starts automatically. Path seams reuse existing environment variables where they already exist: `OPENCODE_CONFIG_DIR` for global config, `OPENCODE_TEST_HOME` for home, and `OPENCODE_DB=:memory:` for the database. Simulation mode should set these before foundational modules load rather than inventing parallel mechanisms. -## Control Server +## Control Servers -The external control surface lives in the TUI/frontend process, not the backend API server. +The UI control surface lives in the TUI/frontend process. A separate backend control surface handles simulated LLM and network operations. This is important because the frontend has direct access to the renderer, screen state, focus state, interactable elements, and user input APIs. The backend remains the normal backend, with only simulation-gated control routes used internally by the frontend when needed. @@ -70,9 +70,10 @@ Protocol: - JSON-RPC 2.0 over WebSocket. - Loopback only. -- Start at `127.0.0.1:40900`. -- If occupied, scan upward and report the actual URL. -- External drivers connect only to this frontend WebSocket. +- `OPENCODE_DRIVE` names a manifest in the opencode-drive registry, or is `1` for the unnamed default endpoints. +- The manifest supplies exact loopback `ui` and `backend` WebSocket endpoints. +- Startup fails rather than scanning when either manifest endpoint is unavailable. +- External drivers connect to both WebSockets when they need UI and backend controls. The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful. @@ -129,7 +130,7 @@ Both fake OpenTUI renderer and visible terminal renderer should share this proto The backend server should be exactly the normal backend server. -Simulation-only backend routes may exist, but only when `OPENCODE_SIMULATION=1`. They are private implementation details for the frontend simulation server to proxy commands like filesystem seeding, LLM scripting, network registration, and snapshots. +Simulation-only backend routes may exist, but only when `OPENCODE_SIMULATE=1`. They are private implementation details for commands like filesystem seeding, LLM scripting, network registration, and snapshots. External drivers should not use backend simulation routes directly. @@ -197,13 +198,13 @@ The anchor may be created by the app itself at activation, or by an external run ## Initial State Snapshot -`OPENCODE_SIMULATION_STATE` points at a directory containing one complete initial state. On startup the app slurps this directory once and constructs all simulated state from it. The snapshot is never written back to; it is a pure input. +`OPENCODE_SIMULATE_STATE` points at a directory containing one complete initial state. On startup the app slurps this directory once and constructs all simulated state from it. The snapshot is never written back to; it is a pure input. Proposed layout: ```text snapshot/ - project/... # workspace files, seeded into the in-memory FS under the anchor root + files/... # workspace files, seeded into the in-memory FS under the anchor root config/opencode.json # global config; the directory backs OPENCODE_CONFIG_DIR env.json # extra environment values to apply llm/... # scripted LLM behavior to pre-enqueue (optional) @@ -212,8 +213,8 @@ snapshot/ Rules: -- Paths inside `project/` are snapshot-relative. The loader joins them onto the anchor root, so absolute virtual paths look like real host paths under the anchor. -- Anything the config references (skills, instructions, reference paths) must exist inside `project/`. A snapshot that references missing files is invalid. +- Paths inside `files/` are snapshot-relative. The loader joins them onto the anchor root, so absolute virtual paths look like real host paths under the anchor. +- Anything the config references (skills, instructions, reference paths) must exist inside `files/`. A snapshot that references missing files is invalid. - The snapshot directory format is the contract between external state generators and the app. Generators (such as the opencode-probe project) produce snapshot directories plus a derived expected model; the app consumes only the snapshot. - Seeding through the control server (`backend.filesystem.seed` and friends) remains available for incremental changes during a run; the snapshot covers initial state. @@ -443,8 +444,8 @@ More advanced model/refinement, metamorphic, and differential properties are fut The first major demo should show this system as a real environment for exploring the app in controlled states: -1. Start opencode normally with `OPENCODE_SIMULATION=1`. -2. TUI starts and exposes the simulation WebSocket on `127.0.0.1:40900+`. +1. Start opencode normally with `OPENCODE_SIMULATE=1` and `OPENCODE_DRIVE=`. +2. TUI and backend start their drive WebSockets at the named manifest endpoints. 3. External runner connects. 4. Runner provides a snapshot directory (or seeds the in-memory project filesystem through the control server). 5. Runner generates and enables plugin-provided config state. @@ -459,10 +460,11 @@ The first major demo should show this system as a real environment for exploring ## Done-When Checklist -- `OPENCODE_SIMULATION=1` starts the normal app with simulation wiring. +- `OPENCODE_SIMULATE=1` starts the normal app with simulation wiring. +- `OPENCODE_DRIVE=` starts both drive WebSockets at the manifest endpoints. - Simulation code is isolated under a dedicated simulation/testing area. - App changes outside simulation are limited to activation hooks, builder replacements, TUI startup, and gated backend routes. -- TUI exposes JSON-RPC WebSocket on `127.0.0.1:40900+`. +- TUI and backend expose JSON-RPC WebSockets at the manifest endpoints. - Driver can call `ui.state`. - Driver can execute generated UI actions. - Fake and visible renderer paths use the same action protocol. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cd0eb32f7c..5c819e521d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -101,7 +101,7 @@ const layer = Layer.effect( const skillDirs = yield* skill.dirs() const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length ? yield* Effect.gen(function* () { - yield* (yield* PluginSupervisor.Service).ready + yield* (yield* PluginSupervisor.Service).flush return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) : [] diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f0763..95dc7f0b18 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -40,7 +40,6 @@ export class Service extends ConfigService.Service()("@opencode/Runtime enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"), - experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"), experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 18d64188a4..2e2292f1af 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -3,7 +3,6 @@ import { Agent } from "@/agent/agent" import { Job } from "@/job" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" @@ -34,10 +33,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeSvc = yield* Worktree.Service const sessions = yield* Session.Service const jobs = yield* Job.Service - const flags = yield* RuntimeFlags.Service const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { - return { backgroundSubagents: flags.experimentalBackgroundSubagents } + return { backgroundSubagents: true } }) const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { @@ -159,7 +157,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: { params: { sessionID: SessionID } }) { - if (!flags.experimentalBackgroundSubagents) return false return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0 }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 641ae2ddf8..2ce86abe43 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -64,7 +64,7 @@ export function fromRow(row: SessionRow): Info { messageID: MessageID.make(row.revert.messageID), partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined, snapshot: row.revert.snapshot, - diff: row.revert.diff, + diff: "diff" in row.revert ? row.revert.diff : undefined, } : undefined return { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 60112e10d9..a42c367ec2 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -51,7 +51,7 @@ type State = { type Data = | { type: "session" - data: SDK.Session + data: SDK.SessionV1Info } | { type: "message" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec2139..e95e8c27a1 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -24,15 +24,8 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" -// Built-in skill that ships with opencode. The model's intuition for what an -// opencode.json should look like is often wrong, and opencode hard-fails on -// invalid config, so users hit cryptic startup errors. Loading this skill -// when the model is asked to touch opencode's own config files gives it the -// actual schemas instead of guesses. -const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" -const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." -const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent +const OPENCODE_SKILL_NAME = "opencode" +const OPENCODE_SKILL_BODY = SkillPlugin.OpencodeContent export const Info = Schema.Struct({ name: Schema.String, @@ -275,11 +268,11 @@ const layer = Layer.effect( const s: State = { skills: {}, dirs: new Set() } // Register the built-in skill BEFORE disk discovery so a user-disk // skill with the same name can override it. - s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { - name: CUSTOMIZE_OPENCODE_SKILL_NAME, - description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + s.skills[OPENCODE_SKILL_NAME] = { + name: OPENCODE_SKILL_NAME, + description: SkillPlugin.OpencodeDescription, location: "", - content: CUSTOMIZE_OPENCODE_SKILL_BODY, + content: OPENCODE_SKILL_BODY, } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4da9bc3ca8..f393caa1a7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" -import { Info } from "@opencode-ai/schema/file-diff" +import { LegacyInfo } from "@opencode-ai/schema/file-diff" export const Patch = Schema.Struct({ hash: Schema.String, @@ -17,7 +17,7 @@ export const Patch = Schema.Struct({ }) export type Patch = typeof Patch.Type -export const FileDiff = Info +export const FileDiff = LegacyInfo export type FileDiff = typeof FileDiff.Type const prune = "7.days" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f61cea1048..4039fd36fa 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,6 +1,5 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" -import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Job } from "@/job" import { Session } from "@/session/session" @@ -12,7 +11,6 @@ import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" -import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" export interface TaskPromptOps { @@ -51,8 +49,6 @@ const BaseParameterFields = { command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), } -const BaseParameters = Schema.Struct(BaseParameterFields) - export const Parameters = Schema.Struct({ ...BaseParameterFields, background: Schema.optional(Schema.Boolean).annotate({ @@ -86,7 +82,6 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope - const flags = yield* RuntimeFlags.Service const database = yield* Database.Service const run = Effect.fn("TaskTool.execute")(function* ( @@ -95,11 +90,6 @@ export const TaskTool = Tool.define( ) { const cfg = yield* config.get() const runInBackground = params.background === true - if (runInBackground && !flags.experimentalBackgroundSubagents) { - return yield* Effect.fail( - new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), - ) - } if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ @@ -333,11 +323,8 @@ export const TaskTool = Tool.define( }) return { - description: flags.experimentalBackgroundSubagents - ? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n") - : DESCRIPTION, + description: [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n"), parameters: Parameters, - jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters), execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 4c3139ce9c..f4f29cc453 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -161,7 +161,6 @@ async function renderFooter( currentModel?: RunInput["model"] currentVariant?: string subagents?: FooterSubagentState - backgroundSubagents?: boolean width?: number height?: number state?: Partial @@ -199,7 +198,6 @@ async function renderFooter( subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} tuiConfig={config} - backgroundSubagents={input.backgroundSubagents ?? true} agent="opencode" onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} @@ -998,7 +996,6 @@ test("direct footer shows editable prompts and additional queued work while runn ]} theme={() => RUN_THEME_FALLBACK} tuiConfig={tuiConfig} - backgroundSubagents={true} agent="opencode" onSubmit={() => true} onPermissionReply={() => {}} @@ -1071,7 +1068,7 @@ test("direct footer shows editable prompts and additional queued work while runn } }) -test("direct footer separates a lone context hint from model and command hint", async () => { +test("direct footer always offers backgrounding for a foreground subagent", async () => { const app = await renderFooter({ providers: [provider()], currentModel: { providerID: "opencode", modelID: "gpt-5" }, @@ -1082,7 +1079,6 @@ test("direct footer separates a lone context hint from model and command hint", permissions: [], questions: [], }, - backgroundSubagents: false, width: 160, }) @@ -1091,8 +1087,8 @@ test("direct footer separates a lone context hint from model and command hint", const frame = app.captureCharFrame() expect(frame).toContain("GPT-5") - expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd") - expect(frame).not.toContain("ctrl+b background") + expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd") + expect(frame).toContain("ctrl+b background") expect(frame).not.toContain("queued") } finally { app.cleanup() @@ -1110,7 +1106,6 @@ test("direct footer hides the subagent hint when only completed subagents remain permissions: [], questions: [], }, - backgroundSubagents: false, width: 160, }) diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index e77d28b62b..377d6c582b 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -28,11 +28,20 @@ function prompted(inputID: string): V2Event { } function settled(outcome: "success" | "interrupted" = "success"): V2Event { + if (outcome === "interrupted") + return { + id: "evt_interrupted", + created: 0, + type: "session.execution.interrupted", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { sessionID: "ses_1", reason: "user" }, + } return { - id: "evt_settled", + id: "evt_succeeded", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome }, + type: "session.execution.succeeded", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { sessionID: "ses_1" }, } } @@ -58,8 +67,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.form, "list").mockImplementation( - (request) => - ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, + (request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, ) spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never) spyOn(sdk.session, "prompt").mockImplementation((request) => { diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index fcba5842fe..5eafd1266c 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -134,7 +134,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -233,7 +232,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -406,7 +404,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: true, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -496,7 +493,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -557,7 +553,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -603,7 +598,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -716,7 +710,6 @@ describe("run interactive runtime", () => { variant: "low", files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index 5400c6c05e..5d0894c0d7 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "node:url" -import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { + OpenCode, + type EventSubscribeOutput, + type MessageListOutput, + type OpenCodeClient, +} from "@opencode-ai/client/promise" import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport" import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types" import { tmpdir } from "../../fixture/fixture" @@ -48,7 +53,13 @@ function connected(id = "evt_connected") { return { id, type: "server.connected", data: {} } satisfies RunV2Event } -function durable(sessionID: string, seq = 0, version = 1) { +function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } +function durable( + sessionID: string, + seq: number, + version: Version, +): { aggregateID: string; seq: number; version: Version } +function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) { return { aggregateID: sessionID, seq, version } } @@ -200,15 +211,16 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", - textID: "txt_1", + ordinal: 0, delta: "answer", }, }) events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await turn @@ -253,8 +265,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -347,8 +360,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -444,8 +458,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -693,7 +708,7 @@ describe("V2 mini transport", () => { type: "assistant", agent: "build", model: { providerID: "test", id: "model" }, - content: [{ type: "text", id: "txt_1", text: "the answer" }], + content: [{ type: "text", text: "the answer" }], time: { created: 2, completed: 3 }, }, ], @@ -705,6 +720,17 @@ describe("V2 mini transport", () => { reset = resolve }) const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) + events.push({ + id: "evt_text_started", + created: 0, + type: "session.text.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + ordinal: 0, + }, + }) events.push({ id: "evt_text", created: 0, @@ -712,7 +738,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", - textID: "txt_1", + ordinal: 0, delta: "answer", }, }) @@ -725,7 +751,7 @@ describe("V2 mini transport", () => { await transport.close() }) - test("scopes repeated text and reasoning ids by assistant message", async () => { + test("scopes text and reasoning ordinals by assistant message", async () => { const events = feed() events.push(connected()) const client = sdk({ streams: [events] }) @@ -738,8 +764,8 @@ describe("V2 mini transport", () => { agent: "build", model: { providerID: "test", id: "model" }, content: [ - { type: "reasoning", id: "reasoning-0", text: "second thought" }, - { type: "text", id: "text-0", text: "second answer" }, + { type: "reasoning", text: "second thought" }, + { type: "text", text: "second answer" }, ], time: { created: 4, completed: 5 }, }, @@ -749,8 +775,8 @@ describe("V2 mini transport", () => { agent: "build", model: { providerID: "test", id: "model" }, content: [ - { type: "reasoning", id: "reasoning-0", text: "first thought" }, - { type: "text", id: "text-0", text: "first answer" }, + { type: "reasoning", text: "first thought" }, + { type: "text", text: "first answer" }, ], time: { created: 2, completed: 3 }, }, @@ -798,7 +824,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", - reasoningID: "reasoning_1", + ordinal: 0, text: "considering", }, }) @@ -808,6 +834,52 @@ describe("V2 mini transport", () => { await transport.close() }) + test("renders a live tool start when the call begins", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + + events.push({ + id: "evt_tool_input", + created: 1, + type: "session.tool.input.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_read", + name: "read", + }, + }) + events.push({ + id: "evt_tool_called", + created: 2, + type: "session.tool.called", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_read", + input: { path: "README.md" }, + executed: false, + }, + }) + await Bun.sleep(0) + + expect(ui.commits).toContainEqual( + expect.objectContaining({ kind: "tool", phase: "start", partID: "prt_call_read", tool: "read" }), + ) + await transport.close() + }) + test("resolves an interrupted turn even when promotion never arrived", async () => { const events = feed() events.push(connected()) @@ -849,8 +921,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.interrupted", + durable: durable("ses_1"), + data: { sessionID: "ses_1", reason: "user" }, }) await turn @@ -913,8 +986,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await turn @@ -975,8 +1049,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.interrupted", + durable: durable("ses_1"), + data: { sessionID: "ses_1", reason: "user" }, }) await turn @@ -1242,17 +1317,10 @@ describe("V2 mini transport", () => { { id: "msg_shell", type: "shell" as const, - shell: { - id: "sh_1", - status: "exited", - command: "ls", - cwd: "/tmp", - shell: "/bin/sh", - file: "/tmp/opencode-shell", - exit: 0, - metadata: {}, - time: { started: 0, completed: 1 }, - }, + shellID: "sh_1", + status: "exited", + command: "ls", + exit: 0, output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, time: { created: 1, completed: 2 }, }, @@ -1309,17 +1377,10 @@ describe("V2 mini transport", () => { { id: "msg_failed_shell", type: "shell" as const, - shell: { - id: "sh_failed", - status: "exited", - command: "false", - cwd: "/tmp", - shell: "/bin/sh", - file: "/tmp/failed", - exit: 7, - metadata: {}, - time: { started: 0, completed: 1 }, - }, + shellID: "sh_failed", + status: "exited", + command: "false", + exit: 7, output: { output: "failure output", cursor: 14, size: 14, truncated: false }, time: { created: 1, completed: 2 }, }, @@ -1413,8 +1474,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -1481,6 +1543,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: input.skill ?? "tigerstyle", name: input.skill ?? "tigerstyle", text: "skill instructions", }, @@ -1488,8 +1551,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok(undefined) as never @@ -1562,6 +1626,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "other", name: "other", text: "other instructions", }, @@ -1569,8 +1634,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_unrelated_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await Bun.sleep(0) await Bun.sleep(0) @@ -1583,6 +1649,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "tigerstyle", name: "tigerstyle", text: "skill instructions", }, @@ -1590,8 +1657,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_skill_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await turn @@ -1649,6 +1717,7 @@ describe("V2 mini transport", () => { { id: "msg_skill", type: "skill" as const, + skill: "tigerstyle", name: "tigerstyle", text: "skill instructions", time: { created: 2 }, @@ -1672,6 +1741,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "tigerstyle", name: "tigerstyle", text: "skill instructions", }, @@ -1701,18 +1771,19 @@ describe("V2 mini transport", () => { ], }, }) - spyOn(client.session, "get").mockImplementation(() => - ok({ - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp" }, - }) as never, + spyOn(client.session, "get").mockImplementation( + () => + ok({ + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never, ) const ui = footer() const transport = await createSessionTransport({ @@ -1750,7 +1821,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_child", assistantMessageID: "msg_child_a", - textID: "txt_child", + ordinal: 0, delta: "child answer", }, }) @@ -1760,8 +1831,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_child", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_child"), + data: { sessionID: "ses_child" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) await transport.close() @@ -1801,7 +1873,9 @@ describe("V2 mini transport", () => { }) await Bun.sleep(0) expect( - states().at(-1)?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"), + states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"), ).toBe(false) events.push({ @@ -1955,19 +2029,36 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_child", assistantMessageID: "msg_overflow_assistant", - textID: `txt_overflow_${index}`, + ordinal: index, delta: `live ${index}`, }, }) - while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")) await Bun.sleep(0) + while ( + !states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "live 64") + ) + await Bun.sleep(0) releaseStale() while (childRequests < 2) await Bun.sleep(0) - expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "live 64"), + ).toBe(true) releaseRetry() - while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "baseline history")) + while ( + !states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "baseline history") + ) await Bun.sleep(0) - expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "live 64"), + ).toBe(true) expect(childRequests).toBe(2) await transport.close() }) @@ -2032,7 +2123,7 @@ describe("V2 mini transport", () => { durable: durable("ses_child", seq), data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name }, }) - const called = (callID: string, tool: string, input: Record, seq: number) => + const called = (callID: string, input: Record, seq: number) => events.push({ id: `evt_called_${callID}`, created: seq, @@ -2042,14 +2133,13 @@ describe("V2 mini transport", () => { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, - tool, input, - provider: { executed: true }, + executed: true, }, }) inputStarted("call_terminal", "grep", 0) - called("call_terminal", "grep", { pattern: "needle" }, 1) + called("call_terminal", { pattern: "needle" }, 1) await Bun.sleep(0) transport.selectSubagent("ses_child") while (!childHydrating) await Bun.sleep(0) @@ -2064,11 +2154,11 @@ describe("V2 mini transport", () => { callID: "call_terminal", structured: {}, content: [{ type: "text", text: "found" }], - provider: { executed: true }, + executed: true, }, }) inputStarted("call_overlap", "bash", 3) - called("call_overlap", "bash", { command: "stale" }, 4) + called("call_overlap", { command: "stale" }, 4) await Bun.sleep(0) const beforeHydration = states().length releaseHydration() @@ -2137,8 +2227,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_child", outcome: "interrupted" }, + type: "session.execution.interrupted", + durable: durable("ses_child"), + data: { sessionID: "ses_child", reason: "user" }, }) await Bun.sleep(0) resolveGet?.() @@ -2192,6 +2283,18 @@ describe("V2 mini transport", () => { }, }) // Parent's background subagent tool.success adopts the child mid-discovery. + events.push({ + id: "evt_parent_input", + created: 0, + type: "session.tool.input.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_parent_a", + callID: "call_sub", + name: "subagent", + }, + }) events.push({ id: "evt_parent_call", created: 0, @@ -2201,9 +2304,8 @@ describe("V2 mini transport", () => { sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", - tool: "subagent", input: { agent: "explore", description: "Find things", prompt: "go", background: true }, - provider: { executed: true }, + executed: true, }, }) events.push({ @@ -2217,15 +2319,16 @@ describe("V2 mini transport", () => { callID: "call_sub", structured: { sessionID: "ses_child", status: "running", output: "" }, content: [], - provider: { executed: true }, + executed: true, }, }) // The settled event arrives after adoption, so it applies directly. events.push({ id: "evt_child_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_child", outcome: "interrupted" }, + type: "session.execution.interrupted", + durable: durable("ses_child"), + data: { sessionID: "ses_child", reason: "shutdown" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index ca5ca1d7b0..51633f57b7 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -51,7 +51,6 @@ describe("RuntimeFlags", () => { expect(flags.enableExperimentalModels).toBe(true) expect(flags.enableQuestionTool).toBe(true) expect(flags.experimentalReferences).toBe(true) - expect(flags.experimentalBackgroundSubagents).toBe(true) expect(flags.experimentalLspTy).toBe(false) expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalOxfmt).toBe(true) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index fa3b1e670e..195699f9c9 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -579,8 +579,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { - check(typeof body === "object" && body !== null, "capabilities should be an object") - check("backgroundSubagents" in body, "capabilities should report background subagents") + check( + typeof body === "object" && body !== null && "backgroundSubagents" in body && body.backgroundSubagents === true, + "capabilities should report background subagents as available", + ) }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9d7643cb33..a5677d5274 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -28,6 +28,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ". import { Database } from "@opencode-ai/core/database/database" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Agent } from "@opencode-ai/schema/agent" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" @@ -76,7 +77,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) { id: MessageID.ascending(), role: "user", sessionID, - agent: "build", + agent: Agent.ID.make("build"), model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, time: { created: Date.now() }, }) @@ -123,7 +124,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = const message = SessionMessage.Assistant.make({ id: SessionMessage.ID.create(), type: "assistant", - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -380,7 +381,7 @@ describe("session HttpApi", () => { yield* insertLegacyAssistantMessage(parent.id) expect( - (yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { + (yield* requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${parent.id}/message`, { headers, })).data, ).toMatchObject([{ type: "assistant" }]) @@ -474,7 +475,7 @@ describe("session HttpApi", () => { }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) - const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage) + const messageBody = yield* json<{ data: SessionMessage.Info[]; cursor: { next?: string } }>(messagePage) const messageCursor = messageBody.cursor.next expect(messageCursor).toBeTruthy() expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id]) @@ -488,7 +489,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id), + (yield* json<{ data: SessionMessage.Info[] }>(nextMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const legacyMessageCursor = Buffer.from( @@ -498,7 +499,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id), + (yield* json<{ data: SessionMessage.Info[] }>(legacyMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const messageCursorWithOrder = yield* request( @@ -625,7 +626,7 @@ describe("session HttpApi", () => { }) expect(wake.status).toBe(200) const message = yield* pollWithTimeout( - requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe( + requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${session.id}/message`, { headers }).pipe( Effect.map(({ data }) => data.find((message) => message.id === wakeID)), ), "V2 prompt was not promoted after wake", @@ -637,28 +638,25 @@ describe("session HttpApi", () => { ) it.instance( - "returns v2 public unavailable errors for unfinished session mutations", + "supports current session compact and wait endpoints", () => Effect.gen(function* () { const test = yield* TestInstance const headers = { "x-opencode-directory": test.directory } const session = yield* createSession({ title: "v2 unavailable" }) - const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers }) - expect(compact.status).toBe(503) - expect(yield* responseJson(compact)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session compact is not available yet", - service: "session.compact", + const compact = yield* request(`/api/session/${session.id}/compact`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(compact.status).toBe(200) + expect(yield* responseJson(compact)).toMatchObject({ + data: { type: "compaction", sessionID: session.id }, }) const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers }) - expect(wait.status).toBe(503) - expect(yield* responseJson(wait)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session wait is not available yet", - service: "session.wait", - }) + expect(wait.status).toBe(204) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index cf27c74cbe..4aa750c39d 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,12 +1,13 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer } from "effect" import { Session as SessionNs } from "@/session/session" +import { Job } from "@/job" import { disposeAllInstances, TestInstance } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(Job.node), httpApiLayer)) afterEach(async () => { mock.restore() @@ -14,6 +15,19 @@ afterEach(async () => { }) describe("session action routes", () => { + it.instance( + "reports background subagents as available", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const res = yield* requestInDirectory("/experimental/capabilities", test.directory) + + expect(res.status).toBe(200) + expect(yield* res.json).toEqual({ backgroundSubagents: true }) + }), + { git: true }, + ) + it.instance( "session routes expose metadata on create, update, get, and fork", () => @@ -107,4 +121,33 @@ describe("session action routes", () => { }), { git: true }, ) + + it.instance( + "experimental background route backgrounds a synchronous subagent", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) => + SessionNs.use.remove(created.id).pipe(Effect.ignore), + ) + const jobs = yield* Job.Service + const job = yield* jobs.start({ type: "task", run: Effect.never }) + const waiting = yield* jobs.block({ id: job.id, sessionID: session.id }).pipe(Effect.forkChild) + + const backgrounded = yield* pollWithTimeout( + requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, { + method: "POST", + }).pipe( + Effect.flatMap((res) => res.json), + Effect.map((value) => (value === true ? true : undefined)), + ), + "background route never released the synchronous subagent", + ) + + expect(backgrounded).toBe(true) + expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } }) + yield* jobs.cancel(job.id) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 7ca09910bf..36853a6d26 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -2,7 +2,6 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" @@ -224,7 +223,7 @@ function isSelected(scenario: RecordedScenario) { const canRun = (scenario: RecordedScenario) => shouldRecord ? scenario.canRecord() - : HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + : HttpRecorder.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) const recordError = (scenario: RecordedScenario) => scenario.id === "openai-oauth" @@ -272,14 +271,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), body: redactRecordedBody, } - const recordedHttp = shouldRecord - ? HttpRecorderInternal.cassetteLayer(scenario.cassette, { - directory: FIXTURES_DIR, - mode: "record", - metadata, - redactor: HttpRecorderInternal.Redactor.make(redact), - }) - : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) + if (shouldRecord) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + } + const recordedHttp = HttpRecorder.layerFetch(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd9..9bb688aedd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1448,6 +1448,7 @@ describe("session.message-v2.fromError", () => { "prompt is too long: 213462 tokens > 200000 maximum", "Your input exceeds the context window of this model", "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)", + "tokens in request more than max tokens allowed", "Please reduce the length of the messages or completion", "400 status code (no body)", "413 status code (no body)", diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac595..456d665950 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -150,7 +150,7 @@ describe("tool.registry", () => { }), ) - it.instance("hides task background parameter unless experimental background subagents are enabled", () => + it.instance("exposes the task background parameter by default", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service @@ -162,8 +162,9 @@ describe("tool.registry", () => { agent: build, })).find((tool) => tool.id === "task") - expect(task?.jsonSchema).toBeDefined() - expect((task?.jsonSchema?.properties as Record | undefined)?.background).toBeUndefined() + if (!task) throw new Error("task tool not found") + const jsonSchema = ToolJsonSchema.fromTool(task) + expect((jsonSchema.properties as Record | undefined)?.background).toBeDefined() }), ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index f62c8b4d31..32171789c8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -34,7 +34,7 @@ const ref = { modelID: ModelV2.ID.make("test-model"), } -const layer = (flags: Partial = {}) => +const layer = () => LayerNode.compile( LayerNode.group([ Agent.node, @@ -52,11 +52,10 @@ const layer = (flags: Partial = {}) => RuntimeFlags.node, Ripgrep.node, ]), - [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + [[RuntimeFlags.node, RuntimeFlags.layer()]], ) const it = testEffect(layer()) -const background = testEffect(layer({ experimentalBackgroundSubagents: true })) function defer() { let resolve!: (value: T | PromiseLike) => void @@ -456,37 +455,6 @@ describe("tool.task", () => { }, ) - it.instance("rejects background execution when the experiment is disabled", () => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - - const exit = yield* def - .execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - background: true, - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps: stubOps() }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - it.instance("backgrounds a running foreground task without restarting it", () => Effect.gen(function* () { const jobs = yield* Job.Service @@ -558,7 +526,7 @@ describe("tool.task", () => { }), ) - background.instance("execute launches background tasks without waiting for completion", () => + it.instance("execute launches background tasks without waiting for completion", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -596,7 +564,7 @@ describe("tool.task", () => { }), ) - background.instance("running task_id reports the existing background task", () => + it.instance("running task_id reports the existing background task", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -661,7 +629,7 @@ describe("tool.task", () => { }), ) - background.instance("background tasks complete through the job service", () => + it.instance("background tasks complete through the job service", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -694,7 +662,7 @@ describe("tool.task", () => { }), ) - background.instance("background task completion does not wait for the parent async prompt", () => + it.instance("background task completion does not wait for the parent async prompt", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -732,7 +700,7 @@ describe("tool.task", () => { }), ) - background.instance("removing the parent session cancels running background tasks", () => + it.instance("removing the parent session cancels running background tasks", () => Effect.gen(function* () { const jobs = yield* Job.Service const sessions = yield* Session.Service @@ -771,7 +739,7 @@ describe("tool.task", () => { }), ) - background.instance("removing the child task session cancels its running background task", () => + it.instance("removing the child task session cancels its running background task", () => Effect.gen(function* () { const jobs = yield* Job.Service const sessions = yield* Session.Service @@ -810,7 +778,7 @@ describe("tool.task", () => { }), ) - background.instance("cancelling the parent run cancels running background tasks", () => + it.instance("cancelling the parent run cancels running background tasks", () => Effect.gen(function* () { const jobs = yield* Job.Service const runState = yield* SessionRunState.Service diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 1fc3dda0df..d29a7398f4 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test" -import { Effect } from "effect" -import * as DateTime from "effect/DateTime" +import { DateTime, Effect } from "effect" import { SessionID } from "../../src/session/schema" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" @@ -8,6 +7,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Agent } from "@opencode-ai/schema/agent" +import { Money } from "@opencode-ai/schema/money" +import { Snapshot } from "@opencode-ai/schema/snapshot" function durable(sessionID: SessionID, seq = 0, version = 1) { return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) } @@ -27,13 +29,13 @@ test.skip("step snapshots carry over to assistant messages", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), variant: ModelV2.VariantID.make("default"), }, - snapshot: "before", + snapshot: Snapshot.ID.make("before"), }, } satisfies SessionEvent.Event), ) @@ -50,21 +52,24 @@ test.skip("step snapshots carry over to assistant messages", () => { sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 }, }, - snapshot: "after", + snapshot: Snapshot.ID.make("after"), }, } satisfies SessionEvent.Event), ) expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return - expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].snapshot).toEqual({ + start: Snapshot.ID.make("before"), + end: Snapshot.ID.make("after"), + }) expect(state.messages[0].finish).toBe("stop") }) @@ -82,7 +87,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -101,7 +106,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - textID: "text-1", + ordinal: 0, }, } satisfies SessionEvent.Event), ) @@ -115,7 +120,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - textID: "text-1", + ordinal: 0, text: "hello assistant", }, } satisfies SessionEvent.Event), @@ -123,7 +128,7 @@ test.skip("text ended populates assistant text content", () => { expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return - expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }]) + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) }) test.skip("tool completion stores completed timestamp", () => { @@ -141,7 +146,7 @@ test.skip("tool completion stores completed timestamp", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -176,9 +181,9 @@ test.skip("tool completion stores completed timestamp", () => { sessionID, assistantMessageID, callID, - tool: "bash", input: { command: "pwd" }, - provider: { executed: true, metadata: { fake: { source: "provider" } } }, + executed: true, + state: { source: "provider" }, }, } satisfies SessionEvent.Event), ) @@ -195,7 +200,8 @@ test.skip("tool completion stores completed timestamp", () => { callID, structured: {}, content: [{ type: "text", text: "/tmp" }], - provider: { executed: true, metadata: { fake: { status: "done" } } }, + executed: true, + resultState: { status: "done" }, }, } satisfies SessionEvent.Event), ) @@ -205,10 +211,14 @@ test.skip("tool completion stores completed timestamp", () => { expect(state.messages[0].content[0]?.type).toBe("tool") if (state.messages[0].content[0]?.type !== "tool") return expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) - expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } }) + expect(state.messages[0].content[0]).toMatchObject({ + executed: true, + providerState: { source: "provider" }, + providerResultState: { status: "done" }, + }) }) -test("compaction events reduce to compaction message only when completed", () => { +test("compaction events reduce to a compaction message through completion", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const id = EventV2.ID.create() @@ -219,15 +229,25 @@ test("compaction events reduce to compaction message only when completed", () => id, created: DateTime.makeUnsafe(0), type: "session.compaction.started", - durable: durable(sessionID), + durable: durable(sessionID, 0, 2), data: { sessionID, reason: "auto", + recent: "recent context", }, } satisfies SessionEvent.Event), ) - expect(state.messages).toEqual([]) + expect(state.messages).toMatchObject([ + { + id: SessionMessage.ID.fromEvent(id), + type: "compaction", + reason: "auto", + recent: "recent context", + status: "running", + summary: "", + }, + ]) Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { @@ -258,7 +278,7 @@ test("compaction events reduce to compaction message only when completed", () => id: endedID, created: DateTime.makeUnsafe(0), type: "session.compaction.ended", - durable: durable(sessionID, 1), + durable: durable(sessionID, 3), data: { sessionID, reason: "auto", @@ -270,9 +290,10 @@ test("compaction events reduce to compaction message only when completed", () => expect(state.messages).toHaveLength(1) expect(state.messages[0]).toMatchObject({ - id: SessionMessage.ID.fromEvent(endedID), + id: SessionMessage.ID.fromEvent(id), type: "compaction", reason: "auto", + status: "completed", summary: "final summary", recent: "recent context", time: { created: DateTime.makeUnsafe(0) }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d93db3e5b0..e590557044 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -14,10 +14,9 @@ "./tool": "./src/tool.ts", "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", - "./v2/effect/integration": "./src/v2/effect/integration.ts", - "./v2/effect/plugin": "./src/v2/effect/plugin.ts", - "./v2/effect/tool": "./src/v2/effect/tool.ts", - "./v2/promise": "./src/v2/promise/index.ts" + "./v2/effect/*": "./src/v2/effect/*.ts", + "./v2": "./src/v2/promise/index.ts", + "./v2/*": "./src/v2/promise/*.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index a9d8d7dcd3..8e2fee5908 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -475,6 +475,7 @@ export type TuiHostSlotMap = { } sidebar_footer: { session_id: string + directory: string } } diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md index 71fa07bd7b..284d1ffe09 100644 --- a/packages/plugin/src/v2/effect/PLAN.md +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -176,7 +176,7 @@ Both use the same low-level scoped registration registry, but consumers invoke t ```ts ctx.tool.transform(...) // replayed to build effective tool registry state -ctx.tool.hook(...) // invoked at a live tool operation boundary +ctx.tool.hook(...) // invoked at a live tool operation boundary ``` The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 3da1d7b566..f2526ec860 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -5,15 +5,13 @@ The Effect plugin API grants plugins two in-process capabilities: - `hook` installs behavior at an OpenCode extension point. - `reload` reruns every transform hook for a stateful domain. -The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. - ## Defining A Plugin ```ts -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export const Plugin = define({ +export default Plugin.define({ id: "example", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((catalog) => { @@ -25,7 +23,7 @@ export const Plugin = define({ }) ``` -Plugin setup registers hooks imperatively. It does not return a hook object. +Plugin setup registers hooks imperatively through each domain's `hook` method. Configuration supplied for the plugin is available as `ctx.options`. @@ -64,7 +62,8 @@ Runtime hooks intercept live operations rather than rebuilding domain state: ```ts yield * - ctx.aisdk.sdk( + ctx.aisdk.hook( + "sdk", Effect.fn(function* (event) { if (event.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) @@ -73,7 +72,7 @@ yield * ) yield * - ctx.aisdk.language((event) => { + ctx.aisdk.hook("language", (event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) }) @@ -81,6 +80,16 @@ yield * Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. +Session request context is mutable immediately before provider dispatch: + +```ts +yield * + ctx.session.hook("request", (event) => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write + }) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index aadacf99d5..d5125cca2a 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,17 +1,17 @@ import type { AgentApi } from "@opencode-ai/client/effect/api" -import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" +import type { AgentInfo } from "@opencode-ai/sdk/v2/types" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface AgentDraft { - list(): readonly AgentV2Info[] - get(id: string): AgentV2Info | undefined + list(): readonly AgentInfo[] + get(id: string): AgentInfo | undefined default(id: string | undefined): void - update(id: string, update: (agent: AgentV2Info) => void): void + update(id: string, update: (agent: AgentInfo) => void): void remove(id: string): void } -export interface AgentHooks extends AgentApi { - readonly transform: TransformHook +export interface AgentDomain extends AgentApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts index 2934edc27d..9539e7b987 100644 --- a/packages/plugin/src/v2/effect/aisdk.ts +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" import type { Hooks } from "./registration.js" -export type AISDKHooks = Hooks<{ +export interface AISDKHooks { sdk: { readonly model: Model.Info readonly package: string @@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{ readonly options: Record language?: LanguageModelV3 } -}> +} + +export interface AISDKDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 4cc5e55227..b96ceec4a8 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,11 +1,11 @@ -import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" +import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { CatalogApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info - readonly models: ReadonlyMap + readonly models: ReadonlyMap } export interface CatalogDraft { @@ -16,8 +16,8 @@ export interface CatalogDraft { remove(providerID: string): void } readonly model: { - get(providerID: string, modelID: string): ModelV2Info | undefined - update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + get(providerID: string, modelID: string): ModelInfo | undefined + update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void remove(providerID: string, modelID: string): void readonly default: { get(): { providerID: string; modelID: string } | undefined @@ -26,7 +26,7 @@ export interface CatalogDraft { } } -export interface CatalogHooks extends CatalogApi { - readonly transform: TransformHook +export interface CatalogDomain extends CatalogApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index 2faaa91fa9..6e4764a485 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,16 +1,16 @@ -import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" +import type { CommandInfo } from "@opencode-ai/sdk/v2/types" import type { CommandApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface CommandDraft { - list(): readonly CommandV2Info[] - get(name: string): CommandV2Info | undefined - update(name: string, update: (command: CommandV2Info) => void): void + list(): readonly CommandInfo[] + get(name: string): CommandInfo | undefined + update(name: string, update: (command: CommandInfo) => void): void remove(name: string): void } -export interface CommandHooks extends CommandApi { - readonly transform: TransformHook +export interface CommandDomain extends CommandApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts deleted file mode 100644 index 219dc16581..0000000000 --- a/packages/plugin/src/v2/effect/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" -import type { AISDKHooks } from "./aisdk.js" -import type { CatalogHooks } from "./catalog.js" -import type { CommandHooks } from "./command.js" -import type { EventHooks } from "./event.js" -import type { IntegrationHooks } from "./integration.js" -import type { PluginDomain } from "./plugin.js" -import type { ReferenceHooks } from "./reference.js" -import type { SkillHooks } from "./skill.js" -import type { ToolDomain } from "./tool.js" -import type { SessionHooks } from "./runtime.js" - -export interface PluginContext { - readonly options: PluginOptions - readonly agent: AgentHooks - readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks - readonly command: CommandHooks - readonly event: EventHooks - readonly integration: IntegrationHooks - readonly plugin: PluginDomain - readonly reference: ReferenceHooks - readonly skill: SkillHooks - readonly tool: ToolDomain - readonly session: SessionHooks -} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts index 49ad375d67..283d4109f0 100644 --- a/packages/plugin/src/v2/effect/event.ts +++ b/packages/plugin/src/v2/effect/event.ts @@ -1,3 +1,3 @@ import type { EventApi } from "@opencode-ai/client/effect/api" -export interface EventHooks extends Pick, "subscribe"> {} +export interface EventDomain extends Pick, "subscribe"> {} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 1332ce2752..e00e302b7a 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,17 +1,4 @@ -export type { PluginContext } from "./context.js" -export { define } from "./plugin.js" -export type { Plugin, PluginDomain } from "./plugin.js" -export type { AgentDraft, AgentHooks } from "./agent.js" -export type { AISDKHooks } from "./aisdk.js" -export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" -export type { CommandDraft, CommandHooks } from "./command.js" -export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" -export type { ReferenceDraft, ReferenceHooks } from "./reference.js" -export type { SkillDraft, SkillHooks } from "./skill.js" -export * as Tool from "./tool.js" -export type { ToolDomain, ToolDraft, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" -export type { SessionHooks } from "./runtime.js" +export * as Plugin from "./plugin.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 1433786634..7af3542b6c 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -11,7 +11,7 @@ import type { } from "@opencode-ai/sdk/v2/types" import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -56,8 +56,8 @@ export interface IntegrationDraft { } } -export interface IntegrationHooks extends IntegrationApi { - readonly transform: TransformHook +export interface IntegrationDomain extends IntegrationApi { + readonly transform: Transform readonly reload: () => Effect.Effect readonly connection: { readonly active: (integrationID: string) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 66f57caf60..3b0b6eb918 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,14 +1,37 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { AgentDomain } from "./agent.js" +import type { AISDKDomain } from "./aisdk.js" +import type { CatalogDomain } from "./catalog.js" +import type { CommandDomain } from "./command.js" +import type { EventDomain } from "./event.js" +import type { IntegrationDomain } from "./integration.js" +import type { ReferenceDomain } from "./reference.js" +import type { SessionDomain } from "./session.js" +import type { SkillDomain } from "./skill.js" +import type { ToolDomain } from "./tool.js" + +export interface Context { + readonly options: PluginOptions + readonly agent: AgentDomain + readonly aisdk: AISDKDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly skill: SkillDomain + readonly tool: ToolDomain +} export interface Plugin { readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect + readonly effect: (context: Context) => Effect.Effect } export function define(plugin: Plugin) { return plugin } - -export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index 1216d3c0b8..085ae0de5e 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,7 +1,7 @@ import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" import type { ReferenceApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -9,7 +9,7 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export interface ReferenceHooks extends ReferenceApi { - readonly transform: TransformHook +export interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index 1ddb9e3489..916b034a73 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -4,10 +4,9 @@ export interface Registration { readonly dispose: Effect.Effect } -export type Hooks = { - readonly [Name in keyof Spec]: ( - callback: (input: Spec[Name]) => Effect.Effect | void, - ) => Effect.Effect -} +export type Hooks = ( + name: Name, + callback: (input: Spec[Name]) => Effect.Effect, +) => Effect.Effect -export type TransformHook = (callback: (input: Input) => void) => Effect.Effect +export type Transform = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts deleted file mode 100644 index 0358330210..0000000000 --- a/packages/plugin/src/v2/effect/runtime.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/effect/api" - -export interface SessionHooks - extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> {} diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts new file mode 100644 index 0000000000..00ee8b9125 --- /dev/null +++ b/packages/plugin/src/v2/effect/session.ts @@ -0,0 +1,25 @@ +import type { SessionApi } from "@opencode-ai/client/effect/api" +import type { Message, SystemPart } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface SessionRequestBeforeEvent { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly request: SessionRequestBeforeEvent +} + +export interface SessionDomain + extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index b01b81bc3b..32daf0fb0a 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,14 +1,14 @@ -import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" +import type { SkillSource } from "@opencode-ai/sdk/v2/types" import type { SkillApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface SkillDraft { - source(source: SkillV2Source): void - list(): readonly SkillV2Source[] + source(source: SkillSource): void + list(): readonly SkillSource[] } -export interface SkillHooks extends SkillApi { - readonly transform: TransformHook +export interface SkillDomain extends SkillApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index bb5931af16..db22b18b4f 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -5,7 +5,7 @@ import { Agent } from "@opencode-ai/schema/agent" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, JsonSchema, Schema, type Scope } from "effect" -import type { Hooks } from "./registration.js" +import type { Hooks, Transform } from "./registration.js" export interface Context { readonly sessionID: Session.ID @@ -253,7 +253,12 @@ export interface ToolDraft { add(name: string, tool: AnyTool, options?: RegisterOptions): void } -export interface ToolDomain { - readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect - readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks } diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index e91b93fbdf..3e96cefc27 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -1,6 +1,6 @@ # OpenCode V2 Promise Plugin API -The Promise plugin API is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: +The Promise plugin API at `@opencode-ai/plugin/v2` is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: - `hook` installs behavior at an OpenCode extension point. - `reload` reruns every transform hook for a stateful domain. @@ -10,9 +10,9 @@ The only difference from the Effect API is the async boundary: hook callbacks, h ## Defining A Plugin ```ts -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export const Plugin = define({ +export default Plugin.define({ id: "example", setup: async (ctx) => { await ctx.catalog.transform((catalog) => { @@ -24,7 +24,7 @@ export const Plugin = define({ }) ``` -Plugin setup registers hooks imperatively. It does not return a hook object. +Plugin setup registers hooks imperatively through each domain's `hook` method. Configuration supplied for the plugin is available as `ctx.options`. @@ -64,18 +64,43 @@ ctx.skill.transform Runtime hooks intercept live operations: ```ts -await ctx.aisdk.sdk(async (event) => { +await ctx.aisdk.hook("sdk", async (event) => { if (event.package !== "@ai-sdk/xai") return const mod = await import("@ai-sdk/xai") event.sdk = mod.createXai(event.options) }) -await ctx.aisdk.language((event) => { +await ctx.aisdk.hook("language", (event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) }) ``` +Session request context is mutable immediately before provider dispatch: + +```ts +await ctx.session.hook("request", (event) => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write +}) +``` + +Promise tools use the same schemas and registration model as Effect tools, with async executors: + +```ts +import { Schema } from "effect" +import { Tool } from "@opencode-ai/plugin/v2/tool" + +const echo = Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: async ({ text }) => ({ text }), +}) + +await ctx.tool.transform((tools) => tools.add("echo", echo)) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts index d9313838cb..bfffdebb4e 100644 --- a/packages/plugin/src/v2/promise/agent.ts +++ b/packages/plugin/src/v2/promise/agent.ts @@ -1,10 +1,10 @@ import type { AgentApi } from "@opencode-ai/client/promise/api" import type { AgentDraft } from "../effect/agent.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { AgentDraft } -export interface AgentHooks extends AgentApi { - readonly transform: TransformHook +export interface AgentDomain extends AgentApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts index 2934edc27d..9539e7b987 100644 --- a/packages/plugin/src/v2/promise/aisdk.ts +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" import type { Hooks } from "./registration.js" -export type AISDKHooks = Hooks<{ +export interface AISDKHooks { sdk: { readonly model: Model.Info readonly package: string @@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{ readonly options: Record language?: LanguageModelV3 } -}> +} + +export interface AISDKDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts index f6b0f649f8..e0a51f5e52 100644 --- a/packages/plugin/src/v2/promise/catalog.ts +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -1,10 +1,10 @@ import type { CatalogApi } from "@opencode-ai/client/promise/api" import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { CatalogDraft, CatalogProviderRecord } -export interface CatalogHooks extends CatalogApi { - readonly transform: TransformHook +export interface CatalogDomain extends CatalogApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts index d042259c47..2c675e2856 100644 --- a/packages/plugin/src/v2/promise/command.ts +++ b/packages/plugin/src/v2/promise/command.ts @@ -1,10 +1,10 @@ import type { CommandApi } from "@opencode-ai/client/promise/api" import type { CommandDraft } from "../effect/command.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { CommandDraft } -export interface CommandHooks extends CommandApi { - readonly transform: TransformHook +export interface CommandDomain extends CommandApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts deleted file mode 100644 index 5e67e44961..0000000000 --- a/packages/plugin/src/v2/promise/context.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" -import type { AISDKHooks } from "./aisdk.js" -import type { CatalogHooks } from "./catalog.js" -import type { CommandHooks } from "./command.js" -import type { EventHooks } from "./event.js" -import type { IntegrationHooks } from "./integration.js" -import type { PluginDomain } from "./plugin.js" -import type { ReferenceHooks } from "./reference.js" -import type { SessionHooks } from "./runtime.js" -import type { SkillHooks } from "./skill.js" - -export interface PluginContext { - readonly options: PluginOptions - readonly agent: AgentHooks - readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks - readonly command: CommandHooks - readonly event: EventHooks - readonly integration: IntegrationHooks - readonly plugin: PluginDomain - readonly reference: ReferenceHooks - readonly session: SessionHooks - readonly skill: SkillHooks -} diff --git a/packages/plugin/src/v2/promise/event.ts b/packages/plugin/src/v2/promise/event.ts index 5330f70c7c..344f5d6f30 100644 --- a/packages/plugin/src/v2/promise/event.ts +++ b/packages/plugin/src/v2/promise/event.ts @@ -1,3 +1,3 @@ import type { EventApi } from "@opencode-ai/client/promise/api" -export interface EventHooks extends Pick {} +export interface EventDomain extends Pick {} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 4f5d5754ea..ae8e6d18a1 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,16 +1,5 @@ -export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" -export { define } from "./plugin.js" -export type { Plugin, PluginDomain } from "./plugin.js" -export type { AgentDraft, AgentHooks } from "./agent.js" -export type { AISDKHooks } from "./aisdk.js" -export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" -export type { CommandDraft, CommandHooks } from "./command.js" -export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" -export type { ReferenceDraft, ReferenceHooks } from "./reference.js" -export type { SessionHooks } from "./runtime.js" -export type { SkillDraft, SkillHooks } from "./skill.js" +export * as Plugin from "./plugin.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index bd133e889e..76e5a99eb5 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,12 +1,12 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationHooks extends IntegrationApi { - readonly transform: TransformHook +export interface IntegrationDomain extends IntegrationApi { + readonly transform: Transform readonly reload: () => Promise readonly connection: { readonly active: (integrationID: string) => Promise diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index eb91b9df53..37bce3cc03 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,13 +1,36 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" -import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { AgentDomain } from "./agent.js" +import type { AISDKDomain } from "./aisdk.js" +import type { CatalogDomain } from "./catalog.js" +import type { CommandDomain } from "./command.js" +import type { EventDomain } from "./event.js" +import type { IntegrationDomain } from "./integration.js" +import type { ReferenceDomain } from "./reference.js" +import type { SessionDomain } from "./session.js" +import type { SkillDomain } from "./skill.js" +import type { ToolDomain } from "./tool.js" + +export interface Context { + readonly options: PluginOptions + readonly agent: AgentDomain + readonly aisdk: AISDKDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly skill: SkillDomain + readonly tool: ToolDomain +} export interface Plugin { readonly id: string - readonly setup: (context: PluginContext) => Promise | void + readonly setup: (context: Context) => Promise | void } export function define(plugin: Plugin) { return plugin } - -export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts index 66bd5b4874..05542b2baf 100644 --- a/packages/plugin/src/v2/promise/reference.ts +++ b/packages/plugin/src/v2/promise/reference.ts @@ -1,10 +1,10 @@ import type { ReferenceApi } from "@opencode-ai/client/promise/api" import type { ReferenceDraft } from "../effect/reference.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { ReferenceDraft } -export interface ReferenceHooks extends ReferenceApi { - readonly transform: TransformHook +export interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts index 76be0982b8..0537c1b98e 100644 --- a/packages/plugin/src/v2/promise/registration.ts +++ b/packages/plugin/src/v2/promise/registration.ts @@ -2,8 +2,9 @@ export interface Registration { readonly dispose: () => Promise } -export type Hooks = { - readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise -} +export type Hooks = ( + name: Name, + callback: (input: Spec[Name]) => Promise | void, +) => Promise -export type TransformHook = (callback: (input: Input) => void) => Promise +export type Transform = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/v2/promise/runtime.ts b/packages/plugin/src/v2/promise/runtime.ts deleted file mode 100644 index b89b6e8abf..0000000000 --- a/packages/plugin/src/v2/promise/runtime.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/promise/api" - -export interface SessionHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts new file mode 100644 index 0000000000..41bf598f46 --- /dev/null +++ b/packages/plugin/src/v2/promise/session.ts @@ -0,0 +1,24 @@ +import type { SessionApi } from "@opencode-ai/client/promise/api" +import type { Message, SystemPart } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface SessionRequestBeforeEvent { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly request: SessionRequestBeforeEvent +} + +export interface SessionDomain extends Pick { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts index a1e62fc544..cbc6459a33 100644 --- a/packages/plugin/src/v2/promise/skill.ts +++ b/packages/plugin/src/v2/promise/skill.ts @@ -1,10 +1,10 @@ import type { SkillApi } from "@opencode-ai/client/promise/api" import type { SkillDraft } from "../effect/skill.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { SkillDraft } -export interface SkillHooks extends SkillApi { - readonly transform: TransformHook +export interface SkillDomain extends SkillApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts new file mode 100644 index 0000000000..da4ee1b200 --- /dev/null +++ b/packages/plugin/src/v2/promise/tool.ts @@ -0,0 +1,110 @@ +export * as Tool from "./tool.js" + +import { Tool } from "../effect/tool.js" +import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Session } from "@opencode-ai/schema/session" +import type { SessionMessage } from "@opencode-ai/schema/session-message" +import { Effect, type JsonSchema, type Schema } from "effect" +import type { Hooks, Transform } from "./registration.js" + +export type Context = Tool.Context +export type SchemaType = Tool.SchemaType +export type Definition, Output extends SchemaType> = Tool.Definition +export type AnyTool = Tool.AnyTool +export const Failure = Tool.Failure +export type Failure = Tool.Failure +export const RegistrationError = Tool.RegistrationError +export type RegistrationError = Tool.RegistrationError +export type Content = Tool.Content +export type DynamicOutput = Tool.DynamicOutput + +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { + readonly description: string + readonly input: Input + readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type + readonly execute: ( + input: Schema.Schema.Type, + context: Context, + ) => Promise> + readonly toModelOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => ReadonlyArray +} + +type DynamicConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: (input: unknown, context: Context) => Promise +} + +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition +export function make(config: DynamicConfig): AnyTool +export function make(config: Config | DynamicConfig): AnyTool { + if ("jsonSchema" in config) + return Tool.make({ + ...config, + execute: (input, context) => Effect.promise(() => config.execute(input, context)), + }) + return Tool.make({ + ...config, + execute: (input, context) => Effect.promise(() => config.execute(input, context)), + }) +} + +export const withPermission = Tool.withPermission + +export interface ToolExecuteBeforeEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + input: unknown +} + +export interface ToolExecuteAfterEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + readonly input: unknown + result: ToolResultValue + output?: ToolOutput + outputPaths?: ReadonlyArray +} + +export interface RegisterOptions { + readonly group?: string + readonly deferred?: boolean +} + +export interface ToolDraft { + add(name: string, tool: AnyTool, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 1abf666796..03f9a34015 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -60,25 +60,5 @@ export const groupNames = { "server.vcs": "vcs", } as const -export const endpointNames = { - "session.messages": "list", - "integration.connect.key": "connectKey", - "integration.connect.oauth": "connectOauth", - "integration.attempt.status": "attemptStatus", - "integration.attempt.complete": "attemptComplete", - "integration.attempt.cancel": "attemptCancel", - "session.instructions.entry.list": ["instructions", "entry", "list"], - "session.instructions.entry.put": ["instructions", "entry", "put"], - "session.instructions.entry.remove": ["instructions", "entry", "remove"], - "session.revert.stage": "revertStage", - "session.revert.clear": "revertClear", - "session.revert.commit": "revertCommit", - "permission.request.list": "listRequests", - "permission.saved.list": "listSaved", - "permission.saved.remove": "removeSaved", - "form.request.list": "listRequests", - "question.request.list": "listRequests", -} as const - export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index a21d3d86d2..d6d1482f30 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Skill } from "@opencode-ai/schema/skill" export class InvalidRequestError extends Schema.TaggedErrorClass()( "InvalidRequestError", @@ -83,7 +84,7 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass()( "SkillNotFoundError", { - skill: Schema.String, + skill: Skill.ID, message: Schema.String, }, { httpApiStatus: 404 }, diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts index b43e5bc639..fd7cd9def5 100644 --- a/packages/protocol/src/groups/debug.ts +++ b/packages/protocol/src/groups/debug.ts @@ -1,6 +1,7 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location.js" export const DebugGroup = HttpApiGroup.make("server.debug") .add( @@ -8,10 +9,24 @@ export const DebugGroup = HttpApiGroup.make("server.debug") success: Schema.Array(Location.Ref), }).annotateMerge( OpenApi.annotations({ - identifier: "v2.debug.location", + identifier: "v2.debug.location.list", summary: "List loaded locations", description: "List locations currently loaded by the server.", }), ), ) + .add( + HttpApiEndpoint.delete("debug.location.evict", "/api/debug/location", { + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.debug.location.evict", + summary: "Evict a loaded location", + description: "Dispose the requested location's cached services so its next use boots them fresh.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "debug" })) diff --git a/packages/protocol/src/groups/mcp.ts b/packages/protocol/src/groups/mcp.ts index d29749ab15..5f9c03935d 100644 --- a/packages/protocol/src/groups/mcp.ts +++ b/packages/protocol/src/groups/mcp.ts @@ -19,4 +19,18 @@ export const McpGroup = HttpApiGroup.make("server.mcp") }), ), ) - .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." })) + .add( + HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", { + query: LocationQuery, + success: Location.response(Mcp.ResourceCatalog), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.resource.catalog", + summary: "List MCP resources", + description: "Retrieve resources and resource templates from connected MCP servers.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." })) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index b8c387576f..5b47146316 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -27,7 +27,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") params: { sessionID: Session.ID }, query: SessionMessagesQuery, success: Schema.Struct({ - data: Schema.Array(SessionMessage.Message), + data: Schema.Array(SessionMessage.Info), cursor: Schema.Struct({ previous: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional), @@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") error: [InvalidCursorError, SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ - identifier: "v2.session.messages", + identifier: "v2.message.list", summary: "Get session messages", description: "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index 309868189f..a8fd1a3bc9 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -107,7 +107,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.pty.connectToken", + identifier: "v2.pty.connect.token", summary: "Create PTY WebSocket token", description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", }), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 8819d7d9e4..e595716873 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -23,9 +23,9 @@ import { UnknownError, } from "../errors.js" import { Agent } from "@opencode-ai/schema/agent" +import { Skill } from "@opencode-ai/schema/skill" import { Model } from "@opencode-ai/schema/model" import { Location } from "@opencode-ai/schema/location" -import { Revert } from "@opencode-ai/schema/revert" import { SessionEvent } from "@opencode-ai/schema/session-event" import { EventLog } from "@opencode-ai/schema/event-log" @@ -189,6 +189,21 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.delete("session.remove", "/api/session/:sessionID", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.remove", + summary: "Delete session", + description: "Delete a session and its child sessions.", + }), + ), + ) .add( HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", { params: { sessionID: Session.ID }, @@ -254,6 +269,25 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + destination: Schema.Struct({ directory: AbsolutePath }), + moveChanges: Schema.Boolean.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, InvalidRequestError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.move", + summary: "Move session", + description: "Move a session to another project directory, optionally transferring local changes.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: Session.ID }, @@ -282,7 +316,7 @@ export const makeSessionGroup = (sessionLo id: SessionMessage.ID.pipe(Schema.optional), command: Schema.String, arguments: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), model: Model.Ref.pipe(Schema.optional), files: PromptInput.Prompt.fields.files, agents: PromptInput.Prompt.fields.agents, @@ -290,13 +324,7 @@ export const makeSessionGroup = (sessionLo resume: Schema.Boolean.pipe(Schema.optional), }), success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ - ConflictError, - InvalidRequestError, - SessionNotFoundError, - CommandNotFoundError, - CommandEvaluationError, - ], + error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], }) .middleware(sessionLocationMiddleware) .annotateMerge( @@ -313,7 +341,7 @@ export const makeSessionGroup = (sessionLo params: { sessionID: Session.ID }, payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional), - skill: Schema.String, + skill: Skill.ID, resume: Schema.Boolean.pipe(Schema.optional), }), success: HttpApiSchema.NoContent, @@ -335,6 +363,7 @@ export const makeSessionGroup = (sessionLo text: Schema.String, description: Schema.String.pipe(Schema.optional), metadata: SessionMessage.Synthetic.fields.metadata, + resume: Schema.Boolean.pipe(Schema.optional), }), success: HttpApiSchema.NoContent, error: SessionNotFoundError, @@ -371,15 +400,16 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, SessionBusyError, ServiceUnavailableError, UnknownError], + payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }), + success: Schema.Struct({ data: SessionInput.Compaction }), + error: [ConflictError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.compact", summary: "Compact session", - description: "Compact a session conversation.", + description: "Queue a durable session compaction request.", }), ), ) @@ -402,7 +432,7 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { params: { sessionID: Session.ID }, payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), - success: Schema.Struct({ data: Revert.State }), + success: Schema.Struct({ data: Session.Revert }), error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError], }) .middleware(sessionLocationMiddleware) @@ -437,7 +467,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { params: { sessionID: Session.ID }, - success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }), error: [SessionNotFoundError, UnknownError], }) .middleware(sessionLocationMiddleware) @@ -553,7 +583,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { params: { sessionID: Session.ID, messageID: SessionMessage.ID }, - success: Schema.Struct({ data: SessionMessage.Message }), + success: Schema.Struct({ data: SessionMessage.Info }), error: [SessionNotFoundError, MessageNotFoundError], }) .middleware(sessionLocationMiddleware) diff --git a/packages/protocol/src/groups/shell.ts b/packages/protocol/src/groups/shell.ts index 31fe35541e..0b4cc081f6 100644 --- a/packages/protocol/src/groups/shell.ts +++ b/packages/protocol/src/groups/shell.ts @@ -1,10 +1,15 @@ import { Shell } from "@opencode-ai/schema/shell" import { Location } from "@opencode-ai/schema/location" +import { NonNegativeInt } from "@opencode-ai/schema/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ShellNotFoundError } from "../errors.js" import { LocationQuery, locationQueryOpenApi } from "./location.js" +const TimeoutInput = Schema.Struct({ + timeout: NonNegativeInt, +}) + export const ShellGroup = HttpApiGroup.make("server.shell") .add( HttpApiEndpoint.get("shell.list", "/api/shell", { @@ -52,6 +57,23 @@ export const ShellGroup = HttpApiGroup.make("server.shell") }), ), ) + .add( + HttpApiEndpoint.patch("shell.timeout", "/api/shell/:id/timeout", { + params: { id: Shell.ID }, + query: LocationQuery, + payload: TimeoutInput, + success: Location.response(Shell.Info), + error: ShellNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.timeout", + summary: "Update shell timeout", + description: "Replace a running shell command's timeout from now, or clear it with zero.", + }), + ), + ) .add( HttpApiEndpoint.get("shell.output", "/api/shell/:id/output", { params: { id: Shell.ID }, diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 33ece777c4..56213ef4cc 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js" test("classifies public events by type", () => { expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true) + expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) }) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index eb84cf500b..81bdfe8c98 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -10,9 +10,12 @@ import { PositiveInt, statics } from "./schema.js" const Updated = ephemeral({ type: "agent.updated", schema: {} }) -export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export const ID = Schema.String.pipe(Schema.brand("Agent.ID")) export type ID = typeof ID.Type +export const Name = Schema.String.pipe(Schema.brand("Agent.Name")) +export type Name = typeof Name.Type + export const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), @@ -22,6 +25,7 @@ export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + name: Name, model: Model.Ref.pipe(optional), request: Provider.Request, system: Schema.String.pipe(optional), @@ -32,12 +36,13 @@ export const Info = Schema.Struct({ steps: PositiveInt.pipe(optional), permissions: Permission.Ruleset, }) - .annotate({ identifier: "AgentV2.Info" }) + .annotate({ identifier: "Agent.Info" }) .pipe( statics((schema) => ({ empty: (id: ID) => schema.make({ id, + name: Name.make(id), request: { settings: {}, headers: {}, body: {} }, mode: "all", hidden: false, diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 81e37157c6..ef32acb821 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -4,6 +4,7 @@ import { Schema } from "effect" import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" +import { Agent } from "./agent.js" const Updated = ephemeral({ type: "command.updated", schema: {} }) @@ -12,10 +13,10 @@ export const Info = Schema.Struct({ name: Schema.String, template: Schema.String, description: Schema.String.pipe(optional), - agent: Schema.String.pipe(optional), + agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), subtask: Schema.Boolean.pipe(optional), -}).annotate({ identifier: "CommandV2.Info" }) +}).annotate({ identifier: "Command.Info" }) export const Event = { Updated, diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 23ed3fd044..035f72568e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory( ...InstallationEvent.Definitions, ...VcsEvent.Definitions, McpEvent.StatusChanged, + McpEvent.ResourcesChanged, // Shared transitional: V1 contracts the current TUI still consumes during // the migration (permission.asked/replied, question.asked, session.error). // Remove when the TUI moves to the current permission/question surfaces. diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 34e4495807..e4f2f9b813 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -1,6 +1,6 @@ export * as Event from "./event.js" -import { Schema } from "effect" +import { Schema, SchemaTransformation } from "effect" import { optional } from "./schema.js" import { ascending } from "./identifier.js" import { Location } from "./location.js" @@ -72,6 +72,7 @@ export type Payload = D extends DurableDefini type Input>>> = { readonly type: Type + readonly identifier?: string readonly durable?: { readonly version: number readonly aggregate: string @@ -84,16 +85,29 @@ export function durable< const Fields extends Readonly>>, >(input: Input & { readonly durable: NonNullable["durable"]> }) { const data = Schema.Struct(input.schema) + const durable = Schema.Struct({ + aggregateID: DurableEnvelope.fields.aggregateID, + seq: DurableEnvelope.fields.seq, + version: Schema.Literal(input.durable.version).pipe( + Schema.decodeTo( + Schema.toType(Version), + SchemaTransformation.transform({ + decode: () => Version.make(input.durable.version), + encode: () => input.durable.version, + }), + ), + ), + }) return Schema.Struct({ id: ID, created: DateTimeUtcFromMillis, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: DurableEnvelope, + durable, location: optional(Location.Ref), data, }) - .annotate({ identifier: input.type }) + .annotate({ identifier: input.identifier ?? input.type }) .pipe( statics(() => ({ type: input.type, @@ -117,7 +131,7 @@ export function ephemeral< location: optional(Location.Ref), data, }) - .annotate({ identifier: input.type }) + .annotate({ identifier: input.identifier ?? input.type }) .pipe( statics(() => ({ type: input.type, diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts index 9847a10574..ff467c58e4 100644 --- a/packages/schema/src/file-diff.ts +++ b/packages/schema/src/file-diff.ts @@ -1,13 +1,23 @@ export * as FileDiff from "./file-diff.js" import { Schema } from "effect" -import { optional } from "./schema.js" +import { NonNegativeInt, optional } from "./schema.js" export const Info = Schema.Struct({ - file: optional(Schema.String), - patch: optional(Schema.String), + file: Schema.String, + patch: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}).annotate({ identifier: "FileDiff.Info" }) +export interface Info extends Schema.Schema.Type {} + +/** V1 snapshot and persisted session diff shape. */ +export const LegacyInfo = Schema.Struct({ + file: Schema.String.pipe(optional), + patch: Schema.String.pipe(optional), additions: Schema.Finite, deletions: Schema.Finite, - status: optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) -export interface Info extends Schema.Schema.Type {} + status: Schema.Literals(["added", "deleted", "modified"]).pipe(optional), +}).annotate({ identifier: "FileDiff.LegacyInfo" }) +export interface LegacyInfo extends Schema.Schema.Type {} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 831f97b066..56bf6fa3c5 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,20 +9,24 @@ export { Form } from "./form.js" export { Integration } from "./integration.js" export { LLM } from "./llm.js" export { Location } from "./location.js" +export { Mcp } from "./mcp.js" export { Model } from "./model.js" +export { Money } from "./money.js" export { Permission } from "./permission.js" export { PermissionSaved } from "./permission-saved.js" export { Project } from "./project.js" export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.js" -export { Revert } from "./revert.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" export { SessionInput } from "./session-input.js" +export { SessionError } from "./session-error.js" export { SessionMessage } from "./session-message.js" +export { Snapshot } from "./snapshot.js" export { Shell } from "./shell.js" export { Skill } from "./skill.js" +export { TokenUsage } from "./token-usage.js" export { Pty } from "./pty.js" export { PtyTicket } from "./pty-ticket.js" export { Question } from "./question.js" diff --git a/packages/schema/src/llm.ts b/packages/schema/src/llm.ts index 5e19ecc6d3..2bc1d93bd8 100644 --- a/packages/schema/src/llm.ts +++ b/packages/schema/src/llm.ts @@ -8,6 +8,9 @@ export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schem }) export type ProviderMetadata = Schema.Schema.Type +export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export type FinishReason = typeof FinishReason.Type + export interface ToolTextContent extends Schema.Schema.Type {} export const ToolTextContent = Schema.Struct({ type: Schema.Literal("text"), diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index ae1e82656d..d41f5f0c4a 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -10,6 +10,13 @@ export const ToolsChanged = Event.ephemeral({ }, }) +export const ResourcesChanged = Event.ephemeral({ + type: "mcp.resources.changed", + schema: { + server: Schema.String, + }, +}) + export const BrowserOpenFailed = Event.ephemeral({ type: "mcp.browser.open.failed", schema: { @@ -27,4 +34,4 @@ export const StatusChanged = Event.ephemeral({ }, }) -export const Definitions = Event.inventory(ToolsChanged, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 4a7ce15618..a86ffe6d67 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -25,14 +25,9 @@ const NeedsClientRegistration = Schema.Struct({ }).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" }) export type Status = typeof Status.Type -export const Status = Schema.Union([ - Connected, - Pending, - Disabled, - Failed, - NeedsAuth, - NeedsClientRegistration, -]).pipe(Schema.toTaggedUnion("status")) +export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe( + Schema.toTaggedUnion("status"), +) export interface Server extends Schema.Schema.Type {} export const Server = Schema.Struct({ @@ -42,3 +37,50 @@ export const Server = Schema.Struct({ // without matching by name, which could collide with provider or plugin integrations. integrationID: optional(IntegrationID), }).annotate({ identifier: "Mcp.Server" }) + +export interface Resource extends Schema.Schema.Type {} +export const Resource = Schema.Struct({ + server: Schema.String, + name: Schema.String, + uri: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), +}).annotate({ identifier: "Mcp.Resource" }) + +export interface ResourceTemplate extends Schema.Schema.Type {} +export const ResourceTemplate = Schema.Struct({ + server: Schema.String, + name: Schema.String, + uriTemplate: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), +}).annotate({ identifier: "Mcp.ResourceTemplate" }) + +export interface ResourceCatalog extends Schema.Schema.Type {} +export const ResourceCatalog = Schema.Struct({ + resources: Schema.Array(Resource), + templates: Schema.Array(ResourceTemplate), +}).annotate({ identifier: "Mcp.ResourceCatalog" }) + +export const ResourceContentPart = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("text"), + uri: Schema.String, + text: Schema.String, + mimeType: optional(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("blob"), + uri: Schema.String, + blob: Schema.String, + mimeType: optional(Schema.String), + }), +]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Mcp.ResourceContentPart" })) +export type ResourceContentPart = typeof ResourceContentPart.Type + +export interface ResourceContent extends Schema.Schema.Type {} +export const ResourceContent = Schema.Struct({ + server: Schema.String, + uri: Schema.String, + contents: Schema.Array(ResourceContentPart), +}).annotate({ identifier: "Mcp.ResourceContent" }) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index fc6b64712f..3539c87f77 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -3,11 +3,12 @@ export * as Model from "./model.js" import { Schema } from "effect" import { optional, statics } from "./schema.js" import { Provider } from "./provider.js" +import { Money } from "./money.js" -export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) export type ID = typeof ID.Type -export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export const VariantID = Schema.String.pipe(Schema.brand("Model.VariantID")) export type VariantID = typeof VariantID.Type export const Ref = Schema.Struct({ @@ -17,7 +18,7 @@ export const Ref = Schema.Struct({ }).annotate({ identifier: "Model.Ref" }) export interface Ref extends Schema.Schema.Type {} -export const Family = Schema.String.pipe(Schema.brand("Family")) +export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type export interface Capabilities extends Schema.Schema.Type {} @@ -30,14 +31,14 @@ export const Capabilities = Schema.Struct({ export interface Cost extends Schema.Schema.Type {} export const Cost = Schema.Struct({ tier: Schema.Struct({ - type: Schema.Literal("context"), + type: Schema.tag("context"), size: Schema.Int, }).pipe(optional), - input: Schema.Finite, - output: Schema.Finite, + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, + read: Money.USDPerMillionTokens, + write: Money.USDPerMillionTokens, }), }).annotate({ identifier: "Model.Cost" }) @@ -70,7 +71,7 @@ export const Info = Schema.Struct({ output: Schema.Int, }), }) - .annotate({ identifier: "ModelV2.Info" }) + .annotate({ identifier: "Model.Info" }) .pipe( statics((schema) => ({ empty: (providerID: Provider.ID, id: ID) => diff --git a/packages/schema/src/money.ts b/packages/schema/src/money.ts new file mode 100644 index 0000000000..f21a390be8 --- /dev/null +++ b/packages/schema/src/money.ts @@ -0,0 +1,18 @@ +export * as Money from "./money.js" + +import { Schema } from "effect" +import { statics } from "./schema.js" + +export const USD = Schema.Finite.pipe( + Schema.brand("Money.USD"), + Schema.annotate({ identifier: "Money.USD" }), + statics((schema) => ({ zero: schema.make(0) })), +) +export type USD = typeof USD.Type + +export const USDPerMillionTokens = Schema.Finite.pipe( + Schema.brand("Money.USDPerMillionTokens"), + Schema.annotate({ identifier: "Money.USDPerMillionTokens" }), + statics((schema) => ({ zero: schema.make(0) })), +) +export type USDPerMillionTokens = typeof USDPerMillionTokens.Type diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts deleted file mode 100644 index ab211a60f3..0000000000 --- a/packages/schema/src/revert.ts +++ /dev/null @@ -1,24 +0,0 @@ -export * as Revert from "./revert.js" - -import { Schema } from "effect" -import { optional } from "./schema.js" -import { NonNegativeInt, RelativePath } from "./schema.js" -import { SessionMessage } from "./session-message.js" - -export const FileDiff = Schema.Struct({ - path: RelativePath, - status: Schema.Literals(["added", "modified", "deleted"]), - additions: NonNegativeInt, - deletions: NonNegativeInt, - patch: Schema.String, -}).annotate({ identifier: "File.Diff" }) -export interface FileDiff extends Schema.Schema.Type {} - -export const State = Schema.Struct({ - messageID: SessionMessage.ID, - partID: Schema.String.pipe(optional), - snapshot: Schema.String.pipe(optional), - diff: Schema.String.pipe(optional), - files: Schema.Array(FileDiff).pipe(optional), -}).annotate({ identifier: "Revert.State" }) -export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/session-error.ts b/packages/schema/src/session-error.ts new file mode 100644 index 0000000000..b11efdbdee --- /dev/null +++ b/packages/schema/src/session-error.ts @@ -0,0 +1,9 @@ +export * as SessionError from "./session-error.js" + +import { Schema } from "effect" + +export interface Error extends Schema.Schema.Type {} +export const Error = Schema.Struct({ + type: Schema.String, + message: Schema.String, +}).annotate({ identifier: "Session.StructuredError" }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index c79b0da746..9c2417e053 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -3,16 +3,23 @@ export * as SessionEvent from "./session-event.js" import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -import { ProviderMetadata, ToolContent } from "./llm.js" +import { ToolContent } from "./llm.js" +import { FinishReason } from "./llm.js" import { Delivery } from "./session-delivery.js" import { Model } from "./model.js" -import { NonNegativeInt, RelativePath } from "./schema.js" +import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" import { FileAttachment, Prompt } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" -import { Revert } from "./revert.js" +import { Revert } from "./session-revert.js" import { Shell as ShellSchema } from "./shell.js" +import { SessionError } from "./session-error.js" +import { Agent } from "./agent.js" +import { Skill as SkillSchema } from "./skill.js" +import { Money } from "./money.js" +import { Snapshot } from "./snapshot.js" +import { TokenUsage } from "./token-usage.js" export { FileAttachment } @@ -21,7 +28,7 @@ export const Source = Schema.Struct({ end: NonNegativeInt, text: Schema.String, }).annotate({ - identifier: "session.event.source", + identifier: "Session.Event.Source", }) export interface Source extends Schema.Schema.Type {} @@ -41,22 +48,12 @@ const options = { version: 1, }, } as const -const stepSettlementOptions = { - durable: { - aggregate: "sessionID", - version: 1, - }, -} as const - -export const UnknownError = SessionMessage.UnknownError -export type UnknownError = SessionMessage.UnknownError - export const AgentSelected = Event.durable({ type: "session.agent.selected", ...options, schema: { ...Base, - agent: Schema.String, + agent: Agent.ID, }, }) export type AgentSelected = typeof AgentSelected.Type @@ -92,6 +89,26 @@ export const Renamed = Event.durable({ }) export type Renamed = typeof Renamed.Type +export const UsageUpdated = Event.ephemeral({ + type: "session.usage.updated", + schema: { + ...Base, + cost: Money.USD, + tokens: TokenUsage.Info, + }, +}) +export type UsageUpdated = typeof UsageUpdated.Type + +export const Deleted = Event.durable({ + type: "session.deleted", + durable: { + aggregate: "sessionID", + version: 2, + }, + schema: Base, +}) +export type Deleted = typeof Deleted.Type + export const Forked = Event.durable({ type: "session.forked", ...options, @@ -120,15 +137,27 @@ export const PromptAdmitted = Event.durable({ }) export type PromptAdmitted = typeof PromptAdmitted.Type -export const ExecutionSettled = Event.ephemeral({ - type: "session.execution.settled", - schema: { - ...Base, - outcome: Schema.Literals(["success", "failure", "interrupted"]), - error: UnknownError.pipe(optional), - }, -}) -export type ExecutionSettled = typeof ExecutionSettled.Type +export namespace Execution { + export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) + export type Started = typeof Started.Type + + export const Succeeded = Event.durable({ type: "session.execution.succeeded", ...options, schema: Base }) + export type Succeeded = typeof Succeeded.Type + + export const Failed = Event.durable({ + type: "session.execution.failed", + ...options, + schema: { ...Base, error: SessionError.Error }, + }) + export type Failed = typeof Failed.Type + + export const Interrupted = Event.durable({ + type: "session.execution.interrupted", + ...options, + schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) }, + }) + export type Interrupted = typeof Interrupted.Type +} export const InstructionsUpdated = Event.durable({ type: "session.instructions.updated", @@ -158,7 +187,8 @@ export namespace Skill { ...options, schema: { ...Base, - name: Schema.String, + id: SkillSchema.ID, + name: SkillSchema.Name, text: Schema.String, }, }) @@ -195,31 +225,23 @@ export namespace Step { schema: { ...Base, assistantMessageID: SessionMessage.ID, - agent: Schema.String, + agent: Agent.ID, model: Model.Ref, - snapshot: Schema.String.pipe(optional), + snapshot: Snapshot.ID.pipe(optional), }, }) export type Started = typeof Started.Type export const Ended = Event.durable({ type: "session.step.ended", - ...stepSettlementOptions, + ...options, schema: { ...Base, assistantMessageID: SessionMessage.ID, - finish: Schema.String, - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - snapshot: Schema.String.pipe(optional), + finish: FinishReason, + cost: Money.USD, + tokens: TokenUsage.Info, + snapshot: Snapshot.ID.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }, }) @@ -227,11 +249,13 @@ export namespace Step { export const Failed = Event.durable({ type: "session.step.failed", - ...stepSettlementOptions, + ...options, schema: { ...Base, assistantMessageID: SessionMessage.ID, - error: UnknownError, + error: SessionError.Error, + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), }, }) export type Failed = typeof Failed.Type @@ -244,7 +268,7 @@ export namespace Text { schema: { ...Base, assistantMessageID: SessionMessage.ID, - textID: Schema.String, + ordinal: NonNegativeInt, }, }) export type Started = typeof Started.Type @@ -255,7 +279,7 @@ export namespace Text { schema: { ...Base, assistantMessageID: SessionMessage.ID, - textID: Schema.String, + ordinal: NonNegativeInt, delta: Schema.String, }, }) @@ -267,7 +291,7 @@ export namespace Text { schema: { ...Base, assistantMessageID: SessionMessage.ID, - textID: Schema.String, + ordinal: NonNegativeInt, text: Schema.String, }, }) @@ -281,8 +305,8 @@ export namespace Reasoning { schema: { ...Base, assistantMessageID: SessionMessage.ID, - reasoningID: Schema.String, - providerMetadata: ProviderMetadata.pipe(optional), + ordinal: NonNegativeInt, + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Started = typeof Started.Type @@ -293,7 +317,7 @@ export namespace Reasoning { schema: { ...Base, assistantMessageID: SessionMessage.ID, - reasoningID: Schema.String, + ordinal: NonNegativeInt, delta: Schema.String, }, }) @@ -305,9 +329,9 @@ export namespace Reasoning { schema: { ...Base, assistantMessageID: SessionMessage.ID, - reasoningID: Schema.String, + ordinal: NonNegativeInt, text: Schema.String, - providerMetadata: ProviderMetadata.pipe(optional), + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Ended = typeof Ended.Type @@ -357,12 +381,9 @@ export namespace Tool { ...options, schema: { ...ToolBase, - tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - }), + executed: Schema.Boolean, + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Called = typeof Called.Type @@ -389,12 +410,9 @@ export namespace Tool { ...ToolBase, structured: Schema.Record(Schema.String, Schema.Unknown), content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(optional), result: Schema.Unknown.pipe(optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - }), + executed: Schema.Boolean, + resultState: SessionMessage.ProviderState.pipe(optional), }, }) export type Success = typeof Success.Type @@ -404,47 +422,47 @@ export namespace Tool { ...options, schema: { ...ToolBase, - error: UnknownError, + error: SessionError.Error, result: Schema.Unknown.pipe(optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - }), + executed: Schema.Boolean, + resultState: SessionMessage.ProviderState.pipe(optional), }, }) export type Failed = typeof Failed.Type } -export const RetryError = Schema.Struct({ - message: Schema.String, - statusCode: Schema.Finite.pipe(optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional), - responseBody: Schema.String.pipe(optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), -}).annotate({ - identifier: "session.retry.error", -}) -export interface RetryError extends Schema.Schema.Type {} - -export const Retried = Event.durable({ - type: "session.retried", +export const RetryScheduled = Event.durable({ + type: "session.retry.scheduled", ...options, schema: { ...Base, - attempt: Schema.Finite, - error: RetryError, + assistantMessageID: SessionMessage.ID, + attempt: PositiveInt, + at: NonNegativeInt, + error: SessionError.Error, }, }) -export type Retried = typeof Retried.Type +export type RetryScheduled = typeof RetryScheduled.Type export namespace Compaction { + export const Admitted = Event.durable({ + type: "session.compaction.admitted", + ...options, + schema: { + ...Base, + inputID: SessionMessage.ID, + }, + }) + export type Admitted = typeof Admitted.Type + export const Started = Event.durable({ type: "session.compaction.started", ...options, schema: { ...Base, - reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + reason: Schema.Literals(["auto", "manual"]), + recent: Schema.String, + inputID: SessionMessage.ID.pipe(optional), }, }) export type Started = typeof Started.Type @@ -469,19 +487,31 @@ export namespace Compaction { }, }) export type Ended = typeof Ended.Type + + export const Failed = Event.durable({ + type: "session.compaction.failed", + ...options, + schema: { + ...Base, + reason: Started.data.fields.reason, + error: SessionError.Error, + inputID: SessionMessage.ID.pipe(optional), + }, + }) + export type Failed = typeof Failed.Type } export namespace RevertEvent { export const Staged = Event.durable({ type: "session.revert.staged", ...options, - schema: { ...Base, revert: Revert.State }, + schema: { ...Base, revert: Revert }, }) export const Cleared = Event.durable({ type: "session.revert.cleared", ...options, schema: Base }) export const Committed = Event.durable({ type: "session.revert.committed", ...options, - schema: { ...Base, messageID: SessionMessage.ID }, + schema: { ...Base, to: SessionMessage.ID }, }) } @@ -490,10 +520,15 @@ export const Definitions = Event.inventory( ModelSelected, Moved, Renamed, + UsageUpdated, + Deleted, Forked, PromptPromoted, PromptAdmitted, - ExecutionSettled, + Execution.Started, + Execution.Succeeded, + Execution.Failed, + Execution.Interrupted, InstructionsUpdated, Synthetic, Skill.Activated, @@ -515,10 +550,12 @@ export const Definitions = Event.inventory( Tool.Progress, Tool.Success, Tool.Failed, - Retried, + RetryScheduled, + Compaction.Admitted, Compaction.Started, Compaction.Delta, Compaction.Ended, + Compaction.Failed, RevertEvent.Staged, RevertEvent.Cleared, RevertEvent.Committed, @@ -530,7 +567,7 @@ export const DurableDefinitions = Event.inventory( export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "SessionDurableEvent" }) + .annotate({ identifier: "Session.Event.Durable" }) export type DurableEvent = typeof Durable.Type export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index eefe68be2b..d1837ba08f 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -21,3 +21,25 @@ export const Admitted = Schema.Struct({ timeCreated: DateTimeUtcFromMillis, promotedSeq: NonNegativeInt.pipe(optional), }).annotate({ identifier: "SessionInput.Admitted" }) + +export interface PromptEntry extends Schema.Schema.Type {} +export const PromptEntry = Schema.Struct({ + type: Schema.tag("prompt"), + ...Admitted.fields, +}).annotate({ identifier: "SessionInput.PromptEntry" }) + +export interface Compaction extends Schema.Schema.Type {} +export const Compaction = Schema.Struct({ + type: Schema.tag("compaction"), + admittedSeq: NonNegativeInt, + id: SessionMessage.ID, + sessionID: SessionID, + timeCreated: DateTimeUtcFromMillis, + handledSeq: NonNegativeInt.pipe(optional), +}).annotate({ identifier: "SessionInput.Compaction" }) + +export const Info = Schema.Union([PromptEntry, Compaction]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SessionInput.Info" }), +) +export type Info = typeof Info.Type diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index c9f39193a5..3d45e283e6 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -2,14 +2,20 @@ export * as SessionMessage from "./session-message.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { ProviderMetadata, ToolContent } from "./llm.js" +import { ToolContent } from "./llm.js" import { Model } from "./model.js" -import { FileAttachment, Prompt } from "./prompt.js" -import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js" -import { SessionID } from "./session-id.js" +import { Prompt } from "./prompt.js" +import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js" import { ascending } from "./identifier.js" import { Event } from "./event.js" import { Shell as ShellSchema } from "./shell.js" +import { FinishReason } from "./llm.js" +import { SessionError } from "./session-error.js" +import { Agent } from "./agent.js" +import { Skill as SkillSchema } from "./skill.js" +import { Money } from "./money.js" +import { Snapshot } from "./snapshot.js" +import { TokenUsage } from "./token-usage.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), @@ -20,29 +26,28 @@ export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( ) export type ID = typeof ID.Type -export interface UnknownError extends Schema.Schema.Type {} -export const UnknownError = Schema.Struct({ - type: Schema.Literal("unknown"), - message: Schema.String, -}).annotate({ identifier: "Session.Error.Unknown" }) - const Base = { id: ID, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis }), } +export const ProviderState = Schema.Record(Schema.String, Schema.Unknown).annotate({ + identifier: "Session.Message.ProviderState", +}) +export type ProviderState = typeof ProviderState.Type + export interface AgentSelected extends Schema.Schema.Type {} export const AgentSelected = Schema.Struct({ ...Base, - type: Schema.Literal("agent-switched"), - agent: Schema.String, + type: Schema.tag("agent-switched"), + agent: Agent.ID, }).annotate({ identifier: "Session.Message.AgentSelected" }) export interface ModelSelected extends Schema.Schema.Type {} export const ModelSelected = Schema.Struct({ ...Base, - type: Schema.Literal("model-switched"), + type: Schema.tag("model-switched"), model: Model.Ref, previous: Model.Ref.pipe(optional), }).annotate({ identifier: "Session.Message.ModelSelected" }) @@ -53,38 +58,41 @@ export const User = Schema.Struct({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, - type: Schema.Literal("user"), + type: Schema.tag("user"), }).annotate({ identifier: "Session.Message.User" }) export interface Synthetic extends Schema.Schema.Type {} export const Synthetic = Schema.Struct({ ...Base, - sessionID: SessionID, text: Schema.String, description: Schema.String.pipe(optional), - type: Schema.Literal("synthetic"), + type: Schema.tag("synthetic"), }).annotate({ identifier: "Session.Message.Synthetic" }) export interface System extends Schema.Schema.Type {} export const System = Schema.Struct({ ...Base, - type: Schema.Literal("system"), + type: Schema.tag("system"), text: Schema.String, }).annotate({ identifier: "Session.Message.System" }) export interface Skill extends Schema.Schema.Type {} export const Skill = Schema.Struct({ ...Base, - type: Schema.Literal("skill"), - name: Schema.String, + type: Schema.tag("skill"), + skill: SkillSchema.ID, + name: SkillSchema.Name, text: Schema.String, }).annotate({ identifier: "Session.Message.Skill" }) export interface Shell extends Schema.Schema.Type {} export const Shell = Schema.Struct({ ...Base, - type: Schema.Literal("shell"), - shell: ShellSchema.Info, + type: Schema.tag("shell"), + shellID: ShellSchema.ID, + command: Schema.String, + status: ShellSchema.Status, + exit: Schema.Number.pipe(optional), output: ShellSchema.Output.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, @@ -92,15 +100,15 @@ export const Shell = Schema.Struct({ }), }).annotate({ identifier: "Session.Message.Shell" }) -export interface ToolStatePending extends Schema.Schema.Type {} -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), +export interface ToolStateStreaming extends Schema.Schema.Type {} +export const ToolStateStreaming = Schema.Struct({ + status: Schema.tag("streaming"), input: Schema.String, -}).annotate({ identifier: "Session.Message.ToolState.Pending" }) +}).annotate({ identifier: "Session.Message.ToolState.Streaming" }) export interface ToolStateRunning extends Schema.Schema.Type {} export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), + status: Schema.tag("running"), input: Schema.Record(Schema.String, Schema.Unknown), structured: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), @@ -108,62 +116,55 @@ export const ToolStateRunning = Schema.Struct({ export interface ToolStateCompleted extends Schema.Schema.Type {} export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), + status: Schema.tag("completed"), input: Schema.Record(Schema.String, Schema.Unknown), - attachments: FileAttachment.pipe(Schema.Array, optional), content: ToolContent.pipe(Schema.Array), - outputPaths: Schema.Array(Schema.String).pipe(optional), structured: Schema.Record(Schema.String, Schema.Unknown), result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Completed" }) export interface ToolStateError extends Schema.Schema.Type {} export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), + status: Schema.tag("error"), input: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), structured: Schema.Record(Schema.String, Schema.Unknown), - error: UnknownError, + error: SessionError.Error, result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Error" }) -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( +export const ToolState = Schema.Union([ToolStateStreaming, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( Schema.toTaggedUnion("status"), ) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError +export type ToolState = ToolStateStreaming | ToolStateRunning | ToolStateCompleted | ToolStateError export interface AssistantTool extends Schema.Schema.Type {} export const AssistantTool = Schema.Struct({ - type: Schema.Literal("tool"), + type: Schema.tag("tool"), id: Schema.String, name: Schema.String, - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - resultMetadata: ProviderMetadata.pipe(optional), - }).pipe(optional), + executed: Schema.Boolean.pipe(optional), + providerState: ProviderState.pipe(optional), + providerResultState: ProviderState.pipe(optional), state: ToolState, time: Schema.Struct({ created: DateTimeUtcFromMillis, ran: DateTimeUtcFromMillis.pipe(optional), completed: DateTimeUtcFromMillis.pipe(optional), - pruned: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Assistant.Tool" }) export interface AssistantText extends Schema.Schema.Type {} export const AssistantText = Schema.Struct({ - type: Schema.Literal("text"), - id: Schema.String, + type: Schema.tag("text"), text: Schema.String, }).annotate({ identifier: "Session.Message.Assistant.Text" }) export interface AssistantReasoning extends Schema.Schema.Type {} export const AssistantReasoning = Schema.Struct({ - type: Schema.Literal("reasoning"), - id: Schema.String, + type: Schema.tag("reasoning"), text: Schema.String, - providerMetadata: ProviderMetadata.pipe(optional), + state: ProviderState.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), @@ -175,43 +176,71 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, ) export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool +export interface AssistantRetry extends Schema.Schema.Type {} +export const AssistantRetry = Schema.Struct({ + attempt: PositiveInt, + at: DateTimeUtcFromMillis, + error: SessionError.Error, +}).annotate({ identifier: "Session.Message.Assistant.Retry" }) + export interface Assistant extends Schema.Schema.Type {} export const Assistant = Schema.Struct({ ...Base, - type: Schema.Literal("assistant"), - agent: Schema.String, + type: Schema.tag("assistant"), + agent: Agent.ID, model: Model.Ref, content: AssistantContent.pipe(Schema.Array), snapshot: Schema.Struct({ - start: Schema.String.pipe(optional), - end: Schema.String.pipe(optional), + start: Snapshot.ID.pipe(optional), + end: Snapshot.ID.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }).pipe(optional), - finish: Schema.String.pipe(optional), - cost: Schema.Finite.pipe(optional), - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), - }).pipe(optional), - error: UnknownError.pipe(optional), + finish: FinishReason.pipe(optional), + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), + error: SessionError.Error.pipe(optional), + retry: AssistantRetry.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Assistant" }) -export interface Compaction extends Schema.Schema.Type {} -export const Compaction = Schema.Struct({ - type: Schema.Literal("compaction"), +const CompactionBase = { type: Schema.tag("compaction"), ...Base } + +export interface CompactionRunning extends Schema.Schema.Type {} +export const CompactionRunning = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("running"), reason: Schema.Literals(["auto", "manual"]), summary: Schema.String, recent: Schema.String, - ...Base, -}).annotate({ identifier: "Session.Message.Compaction" }) +}).annotate({ identifier: "Session.Message.Compaction.Running" }) -export const Message = Schema.Union([ +export interface CompactionCompleted extends Schema.Schema.Type {} +export const CompactionCompleted = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("completed"), + reason: Schema.Literals(["auto", "manual"]), + summary: Schema.String, + recent: Schema.String, +}).annotate({ identifier: "Session.Message.Compaction.Completed" }) + +export interface CompactionFailed extends Schema.Schema.Type {} +export const CompactionFailed = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("failed"), + reason: Schema.Literals(["auto", "manual"]), + error: SessionError.Error, +}).annotate({ identifier: "Session.Message.Compaction.Failed" }) + +export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted, CompactionFailed]).pipe( + Schema.toTaggedUnion("status"), + Schema.annotate({ identifier: "Session.Message.Compaction" }), +) +export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed + +export const Info = Schema.Union([ AgentSelected, ModelSelected, User, @@ -223,6 +252,6 @@ export const Message = Schema.Union([ Compaction, ]) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Session.Message" }) -export type Message = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction -export type Type = Message["type"] + .annotate({ identifier: "Session.Message.Info" }) +export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction +export type Type = Info["type"] diff --git a/packages/schema/src/session-revert.ts b/packages/schema/src/session-revert.ts new file mode 100644 index 0000000000..bbbc3b0f9b --- /dev/null +++ b/packages/schema/src/session-revert.ts @@ -0,0 +1,86 @@ +import { Schema, SchemaTransformation } from "effect" +import { FileDiff } from "./file-diff.js" +import { optional } from "./schema.js" +import { SessionMessage } from "./session-message.js" +import { Snapshot } from "./snapshot.js" + +export interface Revert extends Schema.Schema.Type {} +export const Revert = Schema.Struct({ + messageID: SessionMessage.ID, + /** Legacy V1 compatibility state. */ + partID: Schema.String.pipe(optional), + snapshot: Snapshot.ID.pipe(optional), + files: Schema.Array(FileDiff.Info).pipe(optional), +}).annotate({ identifier: "Session.Revert" }) + +const FileDiffV1 = Schema.Struct({ + path: Schema.String, + status: Schema.Literals(["added", "modified", "deleted"]), + additions: Schema.Finite, + deletions: Schema.Finite, + patch: Schema.String, +}) + +export interface RevertV1 extends Schema.Schema.Type {} +export const RevertV1 = Schema.Struct({ + messageID: SessionMessage.ID, + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiffV1).pipe(optional), +}).annotate({ identifier: "Session.RevertV1" }) + +const PersistedCurrent = Revert.pipe( + Schema.decodeTo( + Schema.Struct({ source: Schema.tag("current"), revert: Schema.toType(Revert) }), + SchemaTransformation.transform({ + decode: (revert): { readonly source: "current"; readonly revert: Revert } => ({ + source: "current", + revert, + }), + encode: (value) => value.revert, + }), + ), +) +const PersistedLegacy = RevertV1.pipe( + Schema.decodeTo( + Schema.Struct({ source: Schema.tag("legacy"), revert: Schema.toType(RevertV1) }), + SchemaTransformation.transform({ + decode: (revert): { readonly source: "legacy"; readonly revert: RevertV1 } => ({ + source: "legacy", + revert, + }), + encode: (value) => value.revert, + }), + ), +) + +/** Storage decoder for revert state written before FileDiff became canonical. */ +export const PersistedRevert = Schema.Union([PersistedCurrent, PersistedLegacy]).pipe( + Schema.toTaggedUnion("source"), + Schema.decodeTo( + Schema.toType(Revert), + SchemaTransformation.transform({ + decode: (persisted): Revert => { + if (persisted.source === "current") return persisted.revert + return Revert.make({ + messageID: persisted.revert.messageID, + partID: persisted.revert.partID, + snapshot: persisted.revert.snapshot ? Snapshot.ID.make(persisted.revert.snapshot) : undefined, + files: persisted.revert.files?.map((file) => ({ + file: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })), + }) + }, + encode: (revert): { readonly source: "current"; readonly revert: Revert } => ({ + source: "current", + revert, + }), + }), + ), + Schema.annotate({ identifier: "Session.Revert.Persisted" }), +) diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 2ae169c138..fffb4b7b3d 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -8,30 +8,32 @@ import { Project } from "./project.js" import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js" import { SessionEvent } from "./session-event.js" import { SessionID } from "./session-id.js" -import { Revert } from "./revert.js" +import { SessionMessage } from "./session-message.js" +import { Money } from "./money.js" +import { TokenUsage } from "./token-usage.js" +import { Revert } from "./session-revert.js" export const ID = SessionID export type ID = SessionID export const Event = SessionEvent +export { Revert } + export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, parentID: ID.pipe(optional), + fork: Schema.Struct({ + sessionID: ID, + /** Messages before this exclusive boundary are copied into the fork. */ + messageID: SessionMessage.ID.pipe(optional), + }).pipe(optional), projectID: Project.ID, agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), + cost: Money.USD, + tokens: TokenUsage.Info, time: Schema.Struct({ created: DateTimeUtcFromMillis, updated: DateTimeUtcFromMillis, @@ -40,8 +42,8 @@ export const Info = Schema.Struct({ title: Schema.String, location: Location.Ref, subpath: RelativePath.pipe(optional), - revert: Revert.State.pipe(optional), -}).annotate({ identifier: "SessionV2.Info" }) + revert: Revert.pipe(optional), +}).annotate({ identifier: "Session.Info" }) export const ListAnchor = Schema.Struct({ id: ID, diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index fbe877c974..59ac25decf 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -6,7 +6,7 @@ import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" -const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("ShellID")) +const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("Shell.ID")) export const ID = IDSchema.pipe( statics((schema: typeof IDSchema) => { @@ -57,7 +57,7 @@ export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, export const CreateInput = Schema.Struct({ command: Schema.String, cwd: optional(Schema.String), - timeout: optional(NonNegativeInt), + timeout: NonNegativeInt, metadata: optional(Metadata), }) export interface CreateInput extends Schema.Schema.Type {} diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index 184266f2ad..69819eee26 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -5,49 +5,56 @@ import { optional } from "./schema.js" import { AbsolutePath } from "./schema.js" import { ephemeral, inventory } from "./event.js" +export const ID = Schema.String.pipe(Schema.brand("Skill.ID")) +export type ID = typeof ID.Type + +export const Name = Schema.String.pipe(Schema.brand("Skill.Name")) +export type Name = typeof Name.Type + export interface DirectorySource extends Schema.Schema.Type {} export const DirectorySource = Schema.Struct({ - type: Schema.Literal("directory"), + type: Schema.tag("directory"), path: AbsolutePath, -}).annotate({ identifier: "SkillV2.DirectorySource" }) +}).annotate({ identifier: "Skill.DirectorySource" }) export interface UrlSource extends Schema.Schema.Type {} export const UrlSource = Schema.Struct({ - type: Schema.Literal("url"), + type: Schema.tag("url"), url: Schema.String, -}).annotate({ identifier: "SkillV2.UrlSource" }) +}).annotate({ identifier: "Skill.UrlSource" }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ - name: Schema.String, + id: ID, + name: Name, description: Schema.String.pipe(optional), slash: Schema.Boolean.pipe(optional), autoinvoke: Schema.Boolean.pipe(optional), location: AbsolutePath, content: Schema.String, -}).annotate({ identifier: "SkillV2.Info" }) +}).annotate({ identifier: "Skill.Info" }) const Updated = ephemeral({ type: "skill.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface EmbeddedSource extends Schema.Schema.Type {} export const EmbeddedSource = Schema.Struct({ - type: Schema.Literal("embedded"), + type: Schema.tag("embedded"), skill: Schema.suspend(() => Info), -}).annotate({ identifier: "SkillV2.EmbeddedSource" }) +}).annotate({ identifier: "Skill.EmbeddedSource" }) export type Source = DirectorySource | UrlSource | EmbeddedSource export const Source = Object.assign( Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( Schema.toTaggedUnion("type"), - Schema.annotate({ identifier: "SkillV2.Source" }), + Schema.annotate({ identifier: "Skill.Source" }), ), { equals: (a: Source, b: Source) => { if (a.type !== b.type) return false if (a.type === "directory" && b.type === "directory") return a.path === b.path if (a.type === "url" && b.type === "url") return a.url === b.url - if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name + if (a.type === "embedded" && b.type === "embedded") return a.skill.id === b.skill.id return false }, key: (source: Source) => @@ -55,6 +62,6 @@ export const Source = Object.assign( ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` - : `embedded:${source.skill.name}`, + : `embedded:${source.skill.id}`, }, ) diff --git a/packages/schema/src/snapshot.ts b/packages/schema/src/snapshot.ts new file mode 100644 index 0000000000..375981b415 --- /dev/null +++ b/packages/schema/src/snapshot.ts @@ -0,0 +1,6 @@ +export * as Snapshot from "./snapshot.js" + +import { Schema } from "effect" + +export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) +export type ID = typeof ID.Type diff --git a/packages/schema/src/token-usage.ts b/packages/schema/src/token-usage.ts new file mode 100644 index 0000000000..6f08add0cc --- /dev/null +++ b/packages/schema/src/token-usage.ts @@ -0,0 +1,14 @@ +export * as TokenUsage from "./token-usage.js" + +import { Schema } from "effect" + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}).annotate({ identifier: "TokenUsage.Info" }) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 1c2827f0d8..3932cae88a 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -2,7 +2,6 @@ export * as SessionV1 from "./session.js" import { Effect, Schema, Types } from "effect" import { durable, ephemeral, inventory } from "../event.js" -import { FileDiff } from "../file-diff.js" import { Project } from "../project.js" import { Provider } from "../provider.js" import { Model } from "../model.js" @@ -11,6 +10,7 @@ import { ascending } from "../identifier.js" import { SessionID } from "../session-id.js" import { WorkspaceID } from "../workspace-id.js" import { PermissionV1 } from "./permission.js" +import { FileDiff } from "../file-diff.js" const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) @@ -340,7 +340,7 @@ export const User = Schema.Struct({ Schema.Struct({ title: Schema.optional(Schema.String), body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff.Info), + diffs: Schema.Array(FileDiff.LegacyInfo), }), ), agent: Schema.String, @@ -510,7 +510,7 @@ const SessionSummary = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, files: Schema.Finite, - diffs: optional(Schema.Array(FileDiff.Info)), + diffs: optional(Schema.Array(FileDiff.LegacyInfo)), }) const SessionTokens = Schema.Struct({ @@ -565,7 +565,7 @@ export const SessionInfo = Schema.Struct({ }), permission: optional(PermissionV1.Ruleset), revert: optional(SessionRevert), -}).annotate({ identifier: "Session" }) +}).annotate({ identifier: "SessionV1.Info" }) export type SessionInfo = typeof SessionInfo.Type const events = { @@ -644,7 +644,7 @@ export const Diff = ephemeral({ type: "session.diff", schema: { sessionID: SessionID, - diff: Schema.Array(FileDiff.Info), + diff: Schema.Array(FileDiff.LegacyInfo), }, }) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 58ba6ba856..f5f310d8d7 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -1,17 +1,39 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { DateTime, Schema } from "effect" import { Agent } from "../src/agent.js" import { FileSystem } from "../src/filesystem.js" +import { Mcp } from "../src/mcp.js" import { Model } from "../src/model.js" import { Project } from "../src/project.js" import { Provider } from "../src/provider.js" import { Pty } from "../src/pty.js" import { Question } from "../src/question.js" import { Session } from "../src/session.js" +import { SessionMessage } from "../src/session-message.js" +import { SessionInput } from "../src/session-input.js" +import { FileDiff } from "../src/file-diff.js" +import { Money } from "../src/money.js" +import { Skill } from "../src/skill.js" +import { Shell } from "../src/shell.js" +import { PersistedRevert } from "../src/session-revert.js" import { SessionTodo } from "../src/session-todo.js" import { optional } from "../src/schema.js" describe("contract hygiene", () => { + test("keeps absolute costs distinct from model rates", () => { + const usd = Money.USD.make(1) + const rate = Money.USDPerMillionTokens.make(1) + // @ts-expect-error Model rates are not absolute costs. + const invalidUSD: Money.USD = rate + // @ts-expect-error Absolute costs are not model rates. + const invalidRate: Money.USDPerMillionTokens = usd + + expect(invalidUSD).toBe(Money.USD.make(1)) + expect(invalidRate).toBe(Money.USDPerMillionTokens.make(1)) + expect(Money.USD.zero).toBe(Money.USD.make(0)) + expect(Money.USDPerMillionTokens.zero).toBe(Money.USDPerMillionTokens.make(0)) + }) + test("optional properties preserve transformations and omit undefined while encoding", () => { const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) @@ -50,6 +72,11 @@ describe("contract hygiene", () => { const identifiers = [ Agent.Color, FileSystem.Submatch, + Mcp.Resource, + Mcp.ResourceTemplate, + Mcp.ResourceCatalog, + Mcp.ResourceContentPart, + Mcp.ResourceContent, Model.Ref, Model.Capabilities, Model.Cost, @@ -64,6 +91,7 @@ describe("contract hygiene", () => { Project.Info, Pty.Info, Session.ListAnchor, + Session.Revert, ].map((schema) => schema.ast.annotations?.identifier) expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) @@ -81,4 +109,92 @@ describe("contract hygiene", () => { expect(source).not.toContain("Schema.Any") expect(source).not.toContain("Schema.mutable") }) + + test("assistant content keeps only domain identities", () => { + expect(SessionMessage.AssistantText.make({ type: "text", text: "hello" })).toEqual({ + type: "text", + text: "hello", + }) + expect( + SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }), + ).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } }) + expect( + SessionMessage.AssistantTool.make({ + type: "tool", + id: "call_1", + name: "search", + executed: true, + providerState: { itemId: "item_1" }, + state: { status: "streaming", input: "" }, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("provider") + }) + + test("reviewed session contracts use their canonical current shapes", () => { + expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info") + expect(SessionInput.Info.ast.annotations?.identifier).toBe("SessionInput.Info") + expect(Money.USD).not.toBe(Money.USDPerMillionTokens) + expect( + FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }), + ).toEqual({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }) + expect( + SessionMessage.Shell.make({ + id: SessionMessage.ID.make("msg_shell"), + type: "shell", + shellID: Shell.ID.make("sh_test"), + command: "pwd", + status: "exited", + exit: 0, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("shell") + expect( + SessionMessage.Skill.make({ + id: SessionMessage.ID.make("msg_skill"), + type: "skill", + skill: Skill.ID.make("effect"), + name: Skill.Name.make("Effect"), + text: "Use Effect", + time: { created: DateTime.makeUnsafe(0) }, + }), + ).toMatchObject({ skill: "effect", name: "Effect" }) + expect( + SessionMessage.CompactionFailed.make({ + id: SessionMessage.ID.make("msg_compaction"), + type: "compaction", + status: "failed", + reason: "manual", + error: { type: "compaction.failed", message: "failed" }, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("summary") + }) + + test("keeps shared persisted revert compatibility", () => { + expect( + Schema.decodeUnknownSync(Session.Revert)({ + messageID: "msg_legacy", + snapshot: "tree", + diff: "legacy patch", + }), + ).not.toHaveProperty("diff") + + const revert = Schema.decodeUnknownSync(PersistedRevert)({ + messageID: "msg_legacy", + snapshot: "tree", + diff: "legacy patch", + files: [{ path: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + expect(String(revert.messageID)).toBe("msg_legacy") + expect(String(revert.snapshot)).toBe("tree") + expect(revert.files).toEqual([ + { file: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }, + ]) + expect(Schema.encodeSync(PersistedRevert)(revert)).toEqual({ + messageID: "msg_legacy", + snapshot: "tree", + files: [{ file: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + }) }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 51400b2e39..971ac68f24 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -17,6 +17,8 @@ import { IdeEvent } from "../src/ide-event.js" import { McpEvent } from "../src/mcp-event.js" import { Plugin } from "../src/plugin.js" import { SessionEvent } from "../src/session-event.js" +import { SessionID } from "../src/session-id.js" +import { SessionMessage } from "../src/session-message.js" import { SessionTodo } from "../src/session-todo.js" import { SessionV1 } from "../src/session-v1.js" import { WorkspaceEvent } from "../src/workspace-event.js" @@ -44,11 +46,13 @@ describe("public event manifest", () => { SessionV1.Event.Error, ]) expect(Array.from(EventManifest.Latest.keys())).toEqual( - EventManifest.Definitions.map((definition) => definition.type), + Array.from(new Set(EventManifest.Definitions.map((definition) => definition.type))), ) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) + expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged) + expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) @@ -73,7 +77,7 @@ describe("public event manifest", () => { expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) - expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) + expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged]) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) @@ -93,6 +97,7 @@ describe("public event manifest", () => { "session.created.1", "session.updated.1", "session.deleted.1", + "session.deleted.2", "message.updated.1", "message.removed.1", "message.part.updated.1", @@ -104,7 +109,10 @@ describe("public event manifest", () => { "session.forked.1", "session.prompt.promoted.1", "session.prompt.admitted.1", - + "session.execution.started.1", + "session.execution.succeeded.1", + "session.execution.failed.1", + "session.execution.interrupted.1", "session.instructions.updated.1", "session.synthetic.1", "session.skill.activated.1", @@ -123,9 +131,11 @@ describe("public event manifest", () => { "session.tool.failed.1", "session.reasoning.started.1", "session.reasoning.ended.1", - "session.retried.1", + "session.retry.scheduled.1", + "session.compaction.admitted.1", "session.compaction.started.1", "session.compaction.ended.1", + "session.compaction.failed.1", "session.revert.staged.1", "session.revert.cleared.1", "session.revert.committed.1", @@ -134,6 +144,51 @@ describe("public event manifest", () => { expect(SessionEvent.DurableDefinitions).toEqual( SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), ) + expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral") + expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral") + expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false) + expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated) expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) + + test("uses the current Session skill event as durable version 1", () => { + expect(EventManifest.Durable.get("session.skill.activated.1")).toBe(SessionEvent.Skill.Activated) + expect(EventManifest.Latest.get("session.skill.activated")).toBe(SessionEvent.Skill.Activated) + }) + + test("keeps simplified session fragment and tool payloads on durable version 1", () => { + const sessionID = SessionID.make("ses_test") + const assistantMessageID = SessionMessage.ID.make("msg_test") + const text = SessionEvent.Text.Started.data.make({ sessionID, assistantMessageID, ordinal: 0 }) + const reasoning = SessionEvent.Reasoning.Ended.data.make({ + sessionID, + assistantMessageID, + ordinal: 0, + text: "thought", + state: { signature: "sig" }, + }) + const tool = SessionEvent.Tool.Called.data.make({ + sessionID, + assistantMessageID, + callID: "call_test", + input: {}, + executed: true, + state: { itemId: "item_test" }, + }) + + expect(text).not.toHaveProperty("textID") + expect(reasoning).not.toHaveProperty("reasoningID") + expect(reasoning).not.toHaveProperty("providerMetadata") + expect(tool).not.toHaveProperty("tool") + expect(tool).not.toHaveProperty("provider") + expect(SessionEvent.Text.Started.durable?.version).toBe(1) + expect(SessionEvent.Tool.Called.durable?.version).toBe(1) + }) + + test("keeps current session deletion minimal", () => { + const sessionID = SessionID.make("ses_test") + + expect(SessionEvent.Deleted.data.make({ sessionID })).toEqual({ sessionID }) + expect(SessionEvent.Deleted.durable?.version).toBe(2) + }) }) diff --git a/packages/schema/test/mcp.test.ts b/packages/schema/test/mcp.test.ts new file mode 100644 index 0000000000..75daa7fe1c --- /dev/null +++ b/packages/schema/test/mcp.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Mcp } from "../src/mcp.js" + +describe("Mcp resources", () => { + test("decodes resource catalogs and omits absent metadata", () => { + const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({ + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }) + + expect(Schema.encodeSync(Mcp.ResourceCatalog)(value)).toEqual({ + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }) + }) + + test("preserves text and base64 blob contents", () => { + expect( + Schema.decodeUnknownSync(Mcp.ResourceContent)({ + server: "docs", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }), + ).toEqual({ + server: "docs", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + }) +}) diff --git a/packages/schema/test/session-error.test.ts b/packages/schema/test/session-error.test.ts new file mode 100644 index 0000000000..de53e1fd5f --- /dev/null +++ b/packages/schema/test/session-error.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { LLM, SessionError } from "../src/index.js" + +describe("SessionError", () => { + test("exports one identified open envelope", () => { + expect(SessionError.Error.ast.annotations?.identifier).toBe("Session.StructuredError") + expect(Object.keys(SessionError).filter((key) => key !== "SessionError")).toEqual(["Error"]) + }) + + test("round trips current and future error types through JSON", () => { + const values: SessionError.Error[] = [ + { type: "provider.rate-limit", message: "Slow down" }, + { type: "provider.auth", message: "Authentication failed" }, + { type: "provider.future-condition", message: "A future provider failure" }, + { type: "unknown", message: "Unexpected" }, + ] + const codec = Schema.fromJsonString(SessionError.Error) + + for (const value of values) { + const encoded = Schema.encodeSync(codec)(value) + expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(value) + } + }) + + test("accepts future fields while exposing only the stable envelope", () => { + expect( + Schema.decodeUnknownSync(SessionError.Error)({ + type: "provider.timeout", + message: "Timeout", + retryAfterMs: 2_500, + }), + ).toEqual({ type: "provider.timeout", message: "Timeout" }) + }) + + test("rejects missing envelope fields", () => { + expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow() + expect(() => Schema.decodeUnknownSync(SessionError.Error)({ message: "Missing type" })).toThrow() + }) +}) + +test("FinishReason is the closed browser-safe provider set", () => { + const reasons = ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const + expect(reasons.map((reason) => Schema.decodeUnknownSync(LLM.FinishReason)(reason))).toEqual([...reasons]) + expect(() => Schema.decodeUnknownSync(LLM.FinishReason)("other")).toThrow() +}) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index bcb06f5319..8ebe230791 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,47 +1,23 @@ import { OpenCode } from "@opencode-ai/client/effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { EventV2 } from "@opencode-ai/core/event" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" -import { Project } from "@opencode-ai/core/project" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer, Scope } from "effect" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Context, Effect, Layer, ManagedRuntime } from "effect" +import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { - const scope = yield* Scope.Scope - const memoMap = yield* Layer.makeMemoMap - const sdkPlugins = SdkPlugins.makeStore() - const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [ - [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], - ]), - memoMap, - scope, + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices)))), + (runtime) => runtime.disposeEffect, ) + const context = yield* runtime.contextEffect const plugins = Context.get(context, SdkPlugins.Service) - const permissions = Context.get(context, PermissionSaved.Service) - const project = Context.get(context, Project.Service) - const web = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - createEmbeddedRoutes(sdkPlugins).pipe( - HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), - HttpRouter.provideRequest(Layer.succeed(Project.Service, project)), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true, memoMap }, - ), - ), - (web) => Effect.promise(web.dispose), - ) - const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + const router = Context.get(context, HttpRouter.HttpRouter) + const handler = HttpEffect.toWebHandler(router.asHttpEffect()) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), { preconnect: () => undefined, }) satisfies typeof globalThis.fetch const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)), ) return { ...client, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index b0efa62909..9dfe7e2a14 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -105,6 +105,50 @@ it.live( 25_000, ) +it.live( + "preserves SDK plugins across Location eviction", + () => + withEmbedded("opencode-embedded-plugin-eviction-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const ref = location(fixture) + const connected = yield* Latch.make(false) + const booted = yield* Deferred.make() + // The rebooted Location commits its second plugin generation. + const recommitted = yield* Deferred.make() + const generations = yield* Ref.make(0) + const id = `evicted-sdk-${crypto.randomUUID()}` + + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") return connected.open + if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void + return Ref.updateAndGet(generations, (total) => total + 1).pipe( + Effect.flatMap((total) => { + if (total === 1) return Deferred.succeed(booted, undefined) + if (total === 2) return Deferred.succeed(recommitted, undefined) + return Effect.void + }), + Effect.asVoid, + ) + }), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.plugin({ id, effect: () => Effect.void }) + + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds")) + yield* opencode.debug.location.evict({ location: ref }) + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds")) + + expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id) + }), + ), + 15_000, +) + it.live( "keeps SDK plugin registration isolated between embedded hosts", () => diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index fdc1d76bc6..20a4fcd803 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -30,6 +30,8 @@ type OpenApiDocument = { const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument +normalizeComponentNames(v2Document) +deduplicateEquivalentComponent(v2Document, "Shell", "Shell1") renameCollidingComponents(document, v2Document) document.paths = { ...document.paths, ...v2Document.paths } document.components = { @@ -60,7 +62,7 @@ if (schemas) { visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { if ( - /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test( name, ) && !reachable.has(name) @@ -100,17 +102,28 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() if ( - /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test( generatedTypes, ) ) { throw new Error("Session history generated duplicate Session event variants") } -const logTypesPatched = generatedTypes.replace( +const sessionErrorTypesPatched = deduplicateEquivalentGeneratedTypes( + generatedTypes, + "SessionStructuredError", + /^SessionStructuredError\d+$/, +) +const obsoleteSessionNext = [...sessionErrorTypesPatched.matchAll(/export type (SessionNext\w*) =/g)].map( + (match) => match[1], +) +if (obsoleteSessionNext.length > 0) { + throw new Error(`Obsolete SessionNext generated type noise reintroduced: ${obsoleteSessionNext.join(", ")}`) +} +const logTypesPatched = sessionErrorTypesPatched.replace( /(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/, "$1number", ) -if (logTypesPatched === generatedTypes) { +if (logTypesPatched === sessionErrorTypesPatched) { throw new Error("Session log numeric query patch did not apply") } const sessionListTypesPatched = logTypesPatched.replace( @@ -121,19 +134,28 @@ if (sessionListTypesPatched === logTypesPatched) { throw new Error("Session list numeric query patch did not apply") } const sessionMessagesTypesPatched = sessionListTypesPatched.replace( - /(export type V2SessionMessagesData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/, + /(export type V2MessageListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/, "$1number$2", ) if (sessionMessagesTypesPatched === sessionListTypesPatched) { throw new Error("Session messages numeric query patch did not apply") } const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace( - /(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/, + /(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStream(?:V2)?;?\s*\};?/, "$1V2Event", ) if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) { throw new Error("Event subscribe response patch did not apply") } +if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) { + throw new Error("Session structured error generated a name-mangled duplicate") +} +if (/\bSessionNext\w*\b/.test(eventSubscribeTypesPatched)) { + throw new Error("Obsolete SessionNext generated type noise reintroduced") +} +if (/export type Shell\d+V2 =/.test(eventSubscribeTypesPatched)) { + throw new Error("Shell generated a name-mangled duplicate") +} await Bun.write(generatedTypesPath, eventSubscribeTypesPatched) const querySerializerPath = "./src/v2/gen/client/utils.gen.ts" @@ -206,6 +228,10 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum const renames = new Map() for (const name of Object.keys(sourceSchemas)) { if (!Object.hasOwn(targetSchemas, name)) continue + if (JSON.stringify(normalizeSchema(sourceSchemas[name])) === JSON.stringify(normalizeSchema(targetSchemas[name]))) { + delete sourceSchemas[name] + continue + } let renamed = `${name}V2` let index = 2 while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) { @@ -225,6 +251,136 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum source.paths = rewriteRefs(source.paths, renames) as Record | undefined } +function normalizeComponentNames(document: OpenApiDocument) { + const schemas = document.components?.schemas + if (!schemas) return + + const canonical = new Map(Object.entries(schemas)) + const renames = new Map() + for (const name of Object.keys(schemas)) { + const next = componentTypeName(name) + if (next === name) continue + const existing = canonical.get(next) + if (existing !== undefined) { + if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(existing))) continue + renames.set(name, next) + continue + } + renames.set(name, next) + canonical.set(next, schemas[name]) + } + if (renames.size === 0) return + + const renamed = new Set() + document.components = { + ...document.components, + schemas: Object.fromEntries( + [ + ...Object.entries(schemas).filter(([name]) => !renames.has(name)), + ...Object.entries(schemas).flatMap(([name, schema]) => { + const next = renames.get(name) + if (!next || Object.hasOwn(schemas, next) || renamed.has(next)) return [] + renamed.add(next) + return [[next, schema] as const] + }), + ].map(([name, schema]) => [name, rewriteRefs(schema, renames)]), + ), + } + document.paths = rewriteRefs(document.paths, renames) as Record | undefined +} + +function componentTypeName(name: string) { + if (!name.includes(".")) return name + return name + .split(".") + .filter((part) => !/^\d+$/.test(part)) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join("") +} + +function deduplicateEquivalentComponent(document: OpenApiDocument, canonical: string, duplicate: string) { + const schemas = document.components?.schemas + if (!schemas?.[canonical] || !schemas[duplicate]) return + if (JSON.stringify(normalizeSchema(schemas[canonical])) !== JSON.stringify(normalizeSchema(schemas[duplicate]))) { + throw new Error(`${duplicate} no longer has the same wire shape as ${canonical}`) + } + + const renames = new Map([[duplicate, canonical]]) + const rewritten = rewriteRefs(schemas, renames) as Record + delete rewritten[duplicate] + document.components = { ...document.components, schemas: rewritten } + document.paths = rewriteRefs(document.paths, renames) as Record | undefined +} + +function deduplicateEquivalentGeneratedTypes(source: string, canonical: string, duplicates: RegExp) { + const canonicalType = generatedType(source, canonical) + if (!canonicalType) throw new Error(`Generated canonical type missing: ${canonical}`) + const names = [...source.matchAll(/export type (\w+) =/g)] + .map((match) => match[1]) + .filter((name): name is string => name !== undefined && duplicates.test(name)) + + return names.reduce((patched, name) => { + const duplicate = generatedType(patched, name) + const currentCanonical = generatedType(patched, canonical) + if (!duplicate || !currentCanonical) throw new Error(`Generated type declaration missing while comparing ${name}`) + if (normalizeGeneratedType(currentCanonical.shape) !== normalizeGeneratedType(duplicate.shape)) { + throw new Error(`${name} no longer has the same generated type shape as ${canonical}`) + } + return (patched.slice(0, duplicate.start) + patched.slice(duplicate.end)).replaceAll(name, canonical) + }, source) +} + +function generatedType(source: string, name: string) { + const start = source.indexOf(`export type ${name} =`) + if (start === -1) return undefined + const next = source.indexOf("\n\nexport type ", start + 1) + const shapeEnd = next === -1 ? source.length : next + return { + start, + end: next === -1 ? source.length : next + 2, + shape: source.slice(source.indexOf("=", start) + 1, shapeEnd), + } +} + +function normalizeGeneratedType(shape: string) { + return shape.replaceAll(/\s/g, "") +} + +function normalizeSchema(value: unknown, key?: string): unknown { + if (Array.isArray(value)) { + const flattened = + key === "anyOf" + ? value.flatMap((item) => + typeof item === "object" && item !== null && Object.keys(item).length === 1 && "anyOf" in item + ? Array.isArray(item.anyOf) + ? item.anyOf + : [item] + : [item], + ) + : value + const expanded = + key === "anyOf" + ? flattened.flatMap((item) => { + if (typeof item !== "object" || item === null || !("type" in item) || !("enum" in item)) return [item] + if (Object.keys(item).some((property) => property !== "type" && property !== "enum")) return [item] + if (!Array.isArray(item.enum)) return [item] + return item.enum.map((member) => ({ type: item.type, enum: [member] })) + }) + : flattened + const normalized = expanded.map((item) => normalizeSchema(item)) + if (key !== "anyOf" && key !== "required" && key !== "enum") return normalized + return [...new Map(normalized.map((item) => [JSON.stringify(item), item])).values()].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ) + } + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([property, child]) => [property, normalizeSchema(child, property)]), + ) +} + function rewriteRefs(value: unknown, renames: Map): unknown { if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames)) if (typeof value !== "object" || value === null) return value diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe0..bd8f984d7f 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -1,5 +1,9 @@ export * from "./gen/types.gen.js" export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" +import type { UserMessage } from "./gen/types.gen.js" + +/** @deprecated V1 snapshot compatibility. Use FileDiffInfo for current API responses. */ +export type SnapshotFileDiff = NonNullable["diffs"]>[number] import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67fd49d378..03d76cc5de 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -76,8 +76,8 @@ import type { FindTextResponses, FormatterStatusErrors, FormatterStatusResponses, - FormCreatePayload2, - FormReply2, + FormCreatePayloadV2, + FormReply, GlobalConfigGetErrors, GlobalConfigGetResponses, GlobalConfigUpdateErrors, @@ -92,8 +92,8 @@ import type { GlobalUpgradeResponses, InstanceDisposeErrors, InstanceDisposeResponses, - InstructionEntryKey2, - LocationRef2, + InstructionEntryKeyV2, + LocationRefV2, LspStatusErrors, LspStatusResponses, McpAddErrors, @@ -114,7 +114,7 @@ import type { McpRemoteConfig, McpStatusErrors, McpStatusResponses, - ModelRef2, + ModelRef, MoveSessionDestination, OutputFormat, Part as Part2, @@ -131,8 +131,8 @@ import type { PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, - PermissionV2Reply2, - PermissionV2Source2, + PermissionV2Reply, + PermissionV2SourceV2, ProjectCommands, ProjectCurrentErrors, ProjectCurrentResponses, @@ -145,9 +145,9 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, - PromptAgentAttachment2, - PromptInputFileAttachment2, - PromptInputV2, + PromptAgentAttachment, + PromptInput, + PromptInputFileAttachment, ProviderAuthErrors, ProviderAuthResponses, ProviderListErrors, @@ -179,7 +179,7 @@ import type { QuestionRejectResponses, QuestionReplyErrors, QuestionReplyResponses, - QuestionV2Reply2, + QuestionV2Reply, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -276,8 +276,10 @@ import type { V2CredentialRemoveResponses, V2CredentialUpdateErrors, V2CredentialUpdateResponses, - V2DebugLocationErrors, - V2DebugLocationResponses, + V2DebugLocationEvictErrors, + V2DebugLocationEvictResponses, + V2DebugLocationListErrors, + V2DebugLocationListResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, V2FormRequestListErrors, @@ -310,6 +312,10 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2McpResourceCatalogErrors, + V2McpResourceCatalogResponses, + V2MessageListErrors, + V2MessageListResponses, V2ModelDefaultErrors, V2ModelDefaultResponses, V2ModelListErrors, @@ -398,8 +404,8 @@ import type { V2SessionLogResponses, V2SessionMessageErrors, V2SessionMessageResponses, - V2SessionMessagesErrors, - V2SessionMessagesResponses, + V2SessionMoveErrors, + V2SessionMoveResponses, V2SessionPermissionCreateErrors, V2SessionPermissionCreateResponses, V2SessionPermissionGetErrors, @@ -416,6 +422,8 @@ import type { V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, V2SessionQuestionReplyResponses, + V2SessionRemoveErrors, + V2SessionRemoveResponses, V2SessionRenameErrors, V2SessionRenameResponses, V2SessionRevertClearErrors, @@ -446,6 +454,8 @@ import type { V2ShellOutputResponses, V2ShellRemoveErrors, V2ShellRemoveResponses, + V2ShellTimeoutErrors, + V2ShellTimeoutResponses, V2SkillListErrors, V2SkillListResponses, V2VcsDiffErrors, @@ -460,7 +470,7 @@ import type { VcsDiffResponses, VcsGetErrors, VcsGetResponses, - VcsMode2, + VcsMode, VcsStatusErrors, VcsStatusResponses, WorktreeCreateErrors, @@ -5292,7 +5302,7 @@ export class Entry extends HeyApiClient { public remove( parameters: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 }, options?: Options, ) { @@ -5326,7 +5336,7 @@ export class Entry extends HeyApiClient { public put( parameters: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 value?: unknown }, options?: Options, @@ -5395,7 +5405,7 @@ export class Form extends HeyApiClient { public create( parameters: { sessionID: string - formCreatePayload: FormCreatePayload2 + formCreatePayloadV2: FormCreatePayloadV2 }, options?: Options, ) { @@ -5405,7 +5415,7 @@ export class Form extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, - { key: "formCreatePayload", map: "body" }, + { key: "formCreatePayloadV2", map: "body" }, ], }, ], @@ -5493,7 +5503,7 @@ export class Form extends HeyApiClient { parameters: { sessionID: string formID: string - formReply: FormReply2 + formReply: FormReply }, options?: Options, ) { @@ -5593,7 +5603,7 @@ export class Permission2 extends HeyApiClient { metadata?: { [key: string]: unknown } - source?: PermissionV2Source2 + source?: PermissionV2SourceV2 agent?: string | null }, options?: Options, @@ -5674,7 +5684,7 @@ export class Permission2 extends HeyApiClient { parameters: { sessionID: string requestID: string - reply?: PermissionV2Reply2 + reply?: PermissionV2Reply message?: string | null }, options?: Options, @@ -5742,7 +5752,7 @@ export class Question2 extends HeyApiClient { parameters: { sessionID: string requestID: string - questionV2Reply: QuestionV2Reply2 + questionV2Reply: QuestionV2Reply }, options?: Options, ) { @@ -5863,8 +5873,8 @@ export class Session3 extends HeyApiClient { parameters?: { id?: string | null agent?: string | null - model?: ModelRef2 | null - location?: LocationRef2 | null + model?: ModelRef | null + location?: LocationRefV2 | null }, options?: Options, ) { @@ -5905,6 +5915,25 @@ export class Session3 extends HeyApiClient { }) } + /** + * Delete session + * + * Delete a session and its child sessions. + */ + public remove( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).delete({ + url: "/api/session/{sessionID}", + ...options, + ...params, + }) + } + /** * Get session * @@ -6006,7 +6035,7 @@ export class Session3 extends HeyApiClient { public switchModel( parameters: { sessionID: string - model?: ModelRef2 + model?: ModelRef }, options?: Options, ) { @@ -6072,6 +6101,45 @@ export class Session3 extends HeyApiClient { }) } + /** + * Move session + * + * Move a session to another project directory, optionally transferring local changes. + */ + public move( + parameters: { + sessionID: string + destination?: { + directory: string + } + moveChanges?: boolean | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "destination" }, + { in: "body", key: "moveChanges" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/move", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * @@ -6081,7 +6149,7 @@ export class Session3 extends HeyApiClient { parameters: { sessionID: string id?: string | null - prompt?: PromptInputV2 + prompt?: PromptInput delivery?: "steer" | "queue" | null resume?: boolean | null }, @@ -6125,9 +6193,9 @@ export class Session3 extends HeyApiClient { command?: string arguments?: string | null agent?: string | null - model?: ModelRef2 | null - files?: Array - agents?: Array + model?: ModelRef | null + files?: Array + agents?: Array delivery?: "steer" | "queue" | null resume?: boolean | null }, @@ -6216,6 +6284,7 @@ export class Session3 extends HeyApiClient { metadata?: { [key: string]: unknown } + resume?: boolean | null }, options?: Options, ) { @@ -6228,6 +6297,7 @@ export class Session3 extends HeyApiClient { { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "resume" }, ], }, ], @@ -6284,19 +6354,35 @@ export class Session3 extends HeyApiClient { /** * Compact session * - * Compact a session conversation. + * Queue a durable session compaction request. */ public compact( parameters: { sessionID: string + id?: string | null }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + ], + }, + ], + ) return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/compact", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } @@ -6440,40 +6526,6 @@ export class Session3 extends HeyApiClient { }) } - /** - * Get session messages - * - * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. - */ - public messages( - parameters: { - sessionID: string - limit?: number | null - order?: "asc" | "desc" | null - cursor?: string | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "limit" }, - { in: "query", key: "order" }, - { in: "query", key: "cursor" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/session/{sessionID}/message", - ...options, - ...params, - }) - } - private _revert?: Revert get revert(): Revert { return (this._revert ??= new Revert({ client: this.client })) @@ -6500,6 +6552,42 @@ export class Session3 extends HeyApiClient { } } +export class Message extends HeyApiClient { + /** + * Get session messages + * + * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public list( + parameters: { + sessionID: string + limit?: number | null + order?: "asc" | "desc" | null + cursor?: string | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) + } +} + export class Model extends HeyApiClient { /** * List models @@ -6559,7 +6647,7 @@ export class Generate extends HeyApiClient { workspace?: string | null } | null prompt?: string - model?: ModelRef2 | null + model?: ModelRef | null }, options?: Options, ) { @@ -6930,6 +7018,34 @@ export class Integration extends HeyApiClient { } } +export class Resource2 extends HeyApiClient { + /** + * List MCP resources + * + * Retrieve resources and resource templates from connected MCP servers. + */ + public catalog( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2McpResourceCatalogResponses, + V2McpResourceCatalogErrors, + ThrowOnError + >({ + url: "/api/mcp/resource", + ...options, + ...params, + }) + } +} + export class Mcp2 extends HeyApiClient { /** * List MCP servers @@ -6952,6 +7068,11 @@ export class Mcp2 extends HeyApiClient { ...params, }) } + + private _resource?: Resource2 + get resource(): Resource2 { + return (this._resource ??= new Resource2({ client: this.client })) + } } export class Credential extends HeyApiClient { @@ -7379,6 +7500,41 @@ export class Event2 extends HeyApiClient { } } +export class Connect2 extends HeyApiClient { + /** + * Create PTY WebSocket token + * + * Create a short-lived single-use ticket for opening a PTY WebSocket connection. + */ + public token( + parameters: { + ptyID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } +} + export class Pty2 extends HeyApiClient { /** * List PTY sessions @@ -7561,39 +7717,6 @@ export class Pty2 extends HeyApiClient { }) } - /** - * Create PTY WebSocket token - * - * Create a short-lived single-use ticket for opening a PTY WebSocket connection. - */ - public connectToken( - parameters: { - ptyID: string - location?: { - directory?: string | null - workspace?: string | null - } | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "location" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/api/pty/{ptyID}/connect-token", - ...options, - ...params, - }) - } - /** * Connect to PTY session * @@ -7629,6 +7752,11 @@ export class Pty2 extends HeyApiClient { ...params, }) } + + private _connect?: Connect2 + get connect2(): Connect2 { + return (this._connect ??= new Connect2({ client: this.client })) + } } export class Shell extends HeyApiClient { @@ -7766,6 +7894,46 @@ export class Shell extends HeyApiClient { }) } + /** + * Update shell timeout + * + * Replace a running shell command's timeout from now, or clear it with zero. + */ + public timeout( + parameters: { + id: string + location?: { + directory?: string | null + workspace?: string | null + } | null + timeout?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "location" }, + { in: "body", key: "timeout" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/api/shell/{id}/timeout", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Read shell output * @@ -8013,7 +8181,7 @@ export class Vcs2 extends HeyApiClient { directory?: string | null workspace?: string | null } | null - mode: VcsMode2 + mode: VcsMode context?: string | null }, options?: Options, @@ -8038,20 +8206,53 @@ export class Vcs2 extends HeyApiClient { } } -export class Debug extends HeyApiClient { +export class Location2 extends HeyApiClient { + /** + * Evict a loaded location + * + * Dispose the requested location's cached services so its next use boots them fresh. + */ + public evict( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).delete< + V2DebugLocationEvictResponses, + V2DebugLocationEvictErrors, + ThrowOnError + >({ + url: "/api/debug/location", + ...options, + ...params, + }) + } + /** * List loaded locations * * List locations currently loaded by the server. */ - public location(options?: Options) { - return (options?.client ?? this.client).get({ + public list(options?: Options) { + return (options?.client ?? this.client).get({ url: "/api/debug/location", ...options, }) } } +export class Debug extends HeyApiClient { + private _location?: Location2 + get location(): Location2 { + return (this._location ??= new Location2({ client: this.client })) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8078,6 +8279,11 @@ export class V2 extends HeyApiClient { return (this._session ??= new Session3({ client: this.client })) } + private _message?: Message + get message(): Message { + return (this._message ??= new Message({ client: this.client })) + } + private _model?: Model get model(): Model { return (this._model ??= new Model({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 70b85f2e0b..0edaac8a7c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,10 +21,14 @@ export type Event = | EventSessionModelSelected | EventSessionMoved | EventSessionRenamed + | EventSessionUsageUpdated | EventSessionForked | EventSessionPromptPromoted | EventSessionPromptAdmitted - | EventSessionExecutionSettled + | EventSessionExecutionStarted + | EventSessionExecutionSucceeded + | EventSessionExecutionFailed + | EventSessionExecutionInterrupted | EventSessionInstructionsUpdated | EventSessionSynthetic | EventSessionSkillActivated @@ -46,10 +50,12 @@ export type Event = | EventSessionToolProgress | EventSessionToolSuccess | EventSessionToolFailed - | EventSessionRetried + | EventSessionRetryScheduled + | EventSessionCompactionAdmitted | EventSessionCompactionStarted | EventSessionCompactionDelta | EventSessionCompactionEnded + | EventSessionCompactionFailed | EventSessionRevertStaged | EventSessionRevertCleared | EventSessionRevertCommitted @@ -90,6 +96,7 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged | EventMcpStatusChanged | EventCommandExecuted | EventFileEdited @@ -164,14 +171,6 @@ export type MoveSessionError = { } } -export type SnapshotFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -182,59 +181,6 @@ export type PermissionRule = { export type PermissionRuleset = Array -export type Session = { - 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?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type OutputFormatText = { type: "text" } @@ -262,7 +208,7 @@ export type UserMessage = { summary?: { title?: string body?: string - diffs: Array + diffs: Array } agent: string model: { @@ -805,7 +751,7 @@ export type GlobalEvent = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } | { @@ -813,7 +759,7 @@ export type GlobalEvent = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } | { @@ -821,7 +767,6 @@ export type GlobalEvent = { type: "session.deleted" properties: { sessionID: string - info: Session } } | { @@ -891,6 +836,15 @@ export type GlobalEvent = { title: string } } + | { + id: string + type: "session.usage.updated" + properties: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } + } | { id: string type: "session.forked" @@ -920,11 +874,32 @@ export type GlobalEvent = { } | { id: string - type: "session.execution.settled" + type: "session.execution.started" properties: { sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown + } + } + | { + id: string + type: "session.execution.succeeded" + properties: { + sessionID: string + } + } + | { + id: string + type: "session.execution.failed" + properties: { + sessionID: string + error: SessionStructuredError + } + } + | { + id: string + type: "session.execution.interrupted" + properties: { + sessionID: string + reason: "user" | "shutdown" | "superseded" } } | { @@ -952,6 +927,7 @@ export type GlobalEvent = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -995,17 +971,9 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -1016,7 +984,9 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } | { @@ -1025,7 +995,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } | { @@ -1034,7 +1004,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number delta: string } } @@ -1044,7 +1014,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -1054,8 +1024,8 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } | { @@ -1064,7 +1034,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number delta: string } } @@ -1074,9 +1044,9 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } | { @@ -1116,14 +1086,11 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } | { @@ -1150,12 +1117,9 @@ export type GlobalEvent = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } | { @@ -1165,21 +1129,29 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } | { id: string - type: "session.retried" + type: "session.retry.scheduled" properties: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError + } + } + | { + id: string + type: "session.compaction.admitted" + properties: { + sessionID: string + inputID: string } } | { @@ -1188,6 +1160,8 @@ export type GlobalEvent = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } | { @@ -1208,12 +1182,22 @@ export type GlobalEvent = { recent: string } } + | { + id: string + type: "session.compaction.failed" + properties: { + sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string + } + } | { id: string type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } | { @@ -1228,7 +1212,7 @@ export type GlobalEvent = { type: "session.revert.committed" properties: { sessionID: string - messageID: string + to: string } } | { @@ -1247,7 +1231,7 @@ export type GlobalEvent = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } | { @@ -1568,6 +1552,13 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } | { id: string type: "mcp.status.changed" @@ -1731,6 +1722,10 @@ export type GlobalEvent = { | SyncEventSessionForked | SyncEventSessionPromptPromoted | SyncEventSessionPromptAdmitted + | SyncEventSessionExecutionStarted + | SyncEventSessionExecutionSucceeded + | SyncEventSessionExecutionFailed + | SyncEventSessionExecutionInterrupted | SyncEventSessionInstructionsUpdated | SyncEventSessionSynthetic | SyncEventSessionSkillActivated @@ -1749,9 +1744,11 @@ export type GlobalEvent = { | SyncEventSessionToolProgress | SyncEventSessionToolSuccess | SyncEventSessionToolFailed - | SyncEventSessionRetried + | SyncEventSessionRetryScheduled + | SyncEventSessionCompactionAdmitted | SyncEventSessionCompactionStarted | SyncEventSessionCompactionEnded + | SyncEventSessionCompactionFailed | SyncEventSessionRevertStaged | SyncEventSessionRevertCleared | SyncEventSessionRevertCommitted @@ -2317,7 +2314,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } cost?: number tokens?: { @@ -2653,6 +2650,59 @@ export type ProviderAuthError1 = { } } +export type Session = { + 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?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -2797,7 +2847,7 @@ export type UnauthorizedError = { } export type SessionsResponse = { - data: Array + data: Array cursor: { previous?: string next?: string @@ -2886,45 +2936,12 @@ export type Shell1 = { } } -export type SessionDurableEvent = - | SessionAgentSelected - | SessionModelSelected - | SessionMoved - | SessionRenamed - | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted - | SessionInstructionsUpdated - | SessionSynthetic - | SessionSkillActivated - | SessionShellStarted - | SessionShellEnded - | SessionStepStarted - | SessionStepEnded - | SessionStepFailed - | SessionTextStarted - | SessionTextEnded - | SessionReasoningStarted - | SessionReasoningEnded - | SessionToolInputStarted - | SessionToolInputEnded - | SessionToolCalled - | SessionToolProgress - | SessionToolSuccess - | SessionToolFailed - | SessionRetried - | SessionCompactionStarted - | SessionCompactionEnded - | SessionRevertStaged - | SessionRevertCleared - | SessionRevertCommitted - -export type SessionLogItem = SessionDurableEvent | EventLogSynced +export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionLogItemStream = string export type SessionMessagesResponse = { - data: Array + data: Array cursor: { previous?: string next?: string @@ -2943,6 +2960,14 @@ export type ProviderNotFoundError = { message: string } +export type McpResource2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + export type FormNotFoundError = { _tag: "FormNotFoundError" id: string @@ -3031,10 +3056,14 @@ export type V2Event = | SessionModelSelected | SessionMoved | SessionRenamed + | SessionUsageUpdated | SessionForked | SessionPromptPromoted | SessionPromptAdmitted - | SessionExecutionSettled + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted | SessionInstructionsUpdated | SessionSynthetic | SessionSkillActivated @@ -3056,10 +3085,12 @@ export type V2Event = | SessionToolProgress | SessionToolSuccess | SessionToolFailed - | SessionRetried + | SessionRetryScheduled + | SessionCompactionAdmitted | SessionCompactionStarted | SessionCompactionDelta | SessionCompactionEnded + | SessionCompactionFailed | SessionRevertStaged | SessionRevertCleared | SessionRevertCommitted @@ -3100,6 +3131,7 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged + | McpResourcesChanged | McpStatusChanged | CommandExecuted | FileEdited @@ -3238,12 +3270,73 @@ export type IntegrationRef = { name: string } -export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource +export type SkillSource = SkillDirectorySource | SkillUrlSource | SkillEmbeddedSource export type MoveSessionDestination = { directory: string } +export type FileDiffLegacyInfo = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +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?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type ModelRef = { id: string providerID: string @@ -3255,6 +3348,18 @@ export type LocationRef = { workspaceID?: string } +export type MoneyUsd = number + +export type TokenUsageInfo = { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } +} + export type PromptBase64 = string export type PromptFileSource = @@ -3286,15 +3391,13 @@ export type PromptAgentAttachment = { mention?: PromptMention } -export type SessionErrorUnknown = { - type: "unknown" +export type SessionStructuredError = { + type: string message: string } -export type LlmProviderMetadata = { - [key: string]: { - [key: string]: unknown - } +export type SessionMessageProviderState = { + [key: string]: unknown } export type ToolTextContent = { @@ -3311,33 +3414,19 @@ export type ToolFileContent = { export type LlmToolContent = ToolTextContent | ToolFileContent -export type SessionRetryError = { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string - } - responseBody?: string - metadata?: { - [key: string]: string - } -} - -export type FileDiff = { - path: string - status: "added" | "modified" | "deleted" +export type FileDiffInfo = { + file: string + patch: string additions: number deletions: number - patch: string + status: "added" | "deleted" | "modified" } -export type RevertState = { +export type SessionRevert = { messageID: string partID?: string snapshot?: string - diff?: string - files?: Array + files?: Array } export type PermissionV2Source = { @@ -3537,7 +3626,7 @@ export type SyncEventSessionCreated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3552,7 +3641,7 @@ export type SyncEventSessionUpdated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3561,13 +3650,12 @@ export type SyncEventSessionDeleted = { type: "sync" id: string syncEvent: { - type: "session.deleted.1" + type: "session.deleted.2" id: string seq: number aggregateID: string data: { sessionID: string - info: Session } } } @@ -3743,6 +3831,64 @@ export type SyncEventSessionPromptAdmitted = { } } +export type SyncEventSessionExecutionStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.started.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + } + } +} + +export type SyncEventSessionExecutionSucceeded = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.succeeded.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + } + } +} + +export type SyncEventSessionExecutionFailed = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.failed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + error: SessionStructuredError + } + } +} + +export type SyncEventSessionExecutionInterrupted = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.interrupted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + reason: "user" | "shutdown" | "superseded" + } + } +} + export type SyncEventSessionInstructionsUpdated = { type: "sync" id: string @@ -3787,6 +3933,7 @@ export type SyncEventSessionSkillActivated = { aggregateID: string data: { sessionID: string + id: string name: string text: string } @@ -3858,17 +4005,9 @@ export type SyncEventSessionStepEnded = { data: { sessionID: string assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -3886,7 +4025,9 @@ export type SyncEventSessionStepFailed = { data: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } } @@ -3902,7 +4043,7 @@ export type SyncEventSessionTextStarted = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } } @@ -3918,7 +4059,7 @@ export type SyncEventSessionTextEnded = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -3935,8 +4076,8 @@ export type SyncEventSessionReasoningStarted = { data: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } } @@ -3952,9 +4093,9 @@ export type SyncEventSessionReasoningEnded = { data: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } } @@ -4005,14 +4146,11 @@ export type SyncEventSessionToolCalled = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } } @@ -4053,12 +4191,9 @@ export type SyncEventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } } @@ -4075,28 +4210,43 @@ export type SyncEventSessionToolFailed = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } } -export type SyncEventSessionRetried = { +export type SyncEventSessionRetryScheduled = { type: "sync" id: string syncEvent: { - type: "session.retried.1" + type: "session.retry.scheduled.1" id: string seq: number aggregateID: string data: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError + } + } +} + +export type SyncEventSessionCompactionAdmitted = { + type: "sync" + id: string + syncEvent: { + type: "session.compaction.admitted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + inputID: string } } } @@ -4112,6 +4262,8 @@ export type SyncEventSessionCompactionStarted = { data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } } @@ -4133,6 +4285,23 @@ export type SyncEventSessionCompactionEnded = { } } +export type SyncEventSessionCompactionFailed = { + type: "sync" + id: string + syncEvent: { + type: "session.compaction.failed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string + } + } +} + export type SyncEventSessionRevertStaged = { type: "sync" id: string @@ -4143,7 +4312,7 @@ export type SyncEventSessionRevertStaged = { aggregateID: string data: { sessionID: string - revert: RevertState + revert: SessionRevert } } } @@ -4172,7 +4341,7 @@ export type SyncEventSessionRevertCommitted = { aggregateID: string data: { sessionID: string - messageID: string + to: string } } } @@ -4242,8 +4411,9 @@ export type PermissionV2Rule = { export type PermissionV2Ruleset = Array -export type AgentV2Info = { +export type AgentInfo = { id: string + name: string model?: ModelRef request: ProviderRequest system?: string @@ -4259,22 +4429,18 @@ export type PluginInfo = { id: string } -export type SessionV2Info = { +export type SessionInfo = { id: string parentID?: string + fork?: { + sessionID: string + messageID?: string + } projectID: string agent?: string model?: ModelRef - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo time: { created: number updated: number @@ -4283,7 +4449,7 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string - revert?: RevertState + revert?: SessionRevert } export type PromptInputFileAttachment = { @@ -4303,6 +4469,15 @@ export type SessionInputAdmitted = { promotedSeq?: number } +export type SessionInputCompaction = { + type: "compaction" + admittedSeq: number + id: string + sessionID: string + timeCreated: number + handledSeq?: number +} + export type SessionMessageAgentSelected = { id: string metadata?: { @@ -4350,7 +4525,6 @@ export type SessionMessageSynthetic = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -4377,6 +4551,7 @@ export type SessionMessageSkill = { created: number } type: "skill" + skill: string name: string text: string } @@ -4391,7 +4566,10 @@ export type SessionMessageShell = { completed?: number } type: "shell" - shell: Shell + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" output?: { output: string cursor: number @@ -4402,23 +4580,21 @@ export type SessionMessageShell = { export type SessionMessageAssistantText = { type: "text" - id: string text: string } export type SessionMessageAssistantReasoning = { type: "reasoning" - id: string text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState time?: { created: number completed?: number } } -export type SessionMessageToolStatePending = { - status: "pending" +export type SessionMessageToolStateStreaming = { + status: "streaming" input: string } @@ -4438,9 +4614,7 @@ export type SessionMessageToolStateCompleted = { input: { [key: string]: unknown } - attachments?: Array content: Array - outputPaths?: Array structured: { [key: string]: unknown } @@ -4456,7 +4630,7 @@ export type SessionMessageToolStateError = { structured: { [key: string]: unknown } - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown } @@ -4464,13 +4638,11 @@ export type SessionMessageAssistantTool = { type: "tool" id: string name: string - provider?: { - executed: boolean - metadata?: LlmProviderMetadata - resultMetadata?: LlmProviderMetadata - } + executed?: boolean + providerState?: SessionMessageProviderState + providerResultState?: SessionMessageProviderState state: - | SessionMessageToolStatePending + | SessionMessageToolStateStreaming | SessionMessageToolStateRunning | SessionMessageToolStateCompleted | SessionMessageToolStateError @@ -4478,10 +4650,15 @@ export type SessionMessageAssistantTool = { created: number ran?: number completed?: number - pruned?: number } } +export type SessionMessageAssistantRetry = { + attempt: number + at: number + error: SessionStructuredError +} + export type SessionMessageAssistant = { id: string metadata?: { @@ -4500,25 +4677,15 @@ export type SessionMessageAssistant = { end?: string files?: Array } - finish?: string - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - error?: SessionErrorUnknown + finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost?: MoneyUsd + tokens?: TokenUsageInfo + error?: SessionStructuredError + retry?: SessionMessageAssistantRetry } -export type SessionMessageCompaction = { +export type SessionMessageCompactionRunning = { type: "compaction" - reason: "auto" | "manual" - summary: string - recent: string id: string metadata?: { [key: string]: unknown @@ -4526,9 +4693,47 @@ export type SessionMessageCompaction = { time: { created: number } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string } -export type SessionMessage = +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionFailed = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError +} + +export type SessionMessageCompaction = + | SessionMessageCompactionRunning + | SessionMessageCompactionCompleted + | SessionMessageCompactionFailed + +export type SessionMessageInfo = | SessionMessageAgentSelected | SessionMessageModelSelected | SessionMessageUser @@ -4556,7 +4761,7 @@ export type SessionAgentSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4575,7 +4780,7 @@ export type SessionModelSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4594,7 +4799,7 @@ export type SessionMoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4614,7 +4819,7 @@ export type SessionRenamed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4623,6 +4828,24 @@ export type SessionRenamed = { } } +export type SessionDeleted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable: { + aggregateID: string + seq: number + version: 2 + } + location?: LocationRef + data: { + sessionID: string + } +} + export type SessionForked = { id: string created: number @@ -4633,7 +4856,7 @@ export type SessionForked = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4653,7 +4876,7 @@ export type SessionPromptPromoted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4672,7 +4895,7 @@ export type SessionPromptAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4683,6 +4906,80 @@ export type SessionPromptAdmitted = { } } +export type SessionExecutionStarted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type SessionExecutionSucceeded = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.succeeded" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type SessionExecutionFailed = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.failed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRef + data: { + sessionID: string + error: SessionStructuredError + } +} + +export type SessionExecutionInterrupted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + 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 @@ -4693,7 +4990,7 @@ export type SessionInstructionsUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4712,7 +5009,7 @@ export type SessionSynthetic = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4735,11 +5032,12 @@ export type SessionSkillActivated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + id: string name: string text: string } @@ -4755,7 +5053,7 @@ export type SessionShellStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4774,7 +5072,7 @@ export type SessionShellEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4799,7 +5097,7 @@ export type SessionStepStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4821,23 +5119,15 @@ export type SessionStepEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -4853,13 +5143,15 @@ export type SessionStepFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -4873,13 +5165,13 @@ export type SessionTextStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } @@ -4893,13 +5185,13 @@ export type SessionTextEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -4914,14 +5206,14 @@ export type SessionReasoningStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } @@ -4935,15 +5227,15 @@ export type SessionReasoningEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } @@ -4957,7 +5249,7 @@ export type SessionToolInputStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4978,7 +5270,7 @@ export type SessionToolInputEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4999,21 +5291,18 @@ export type SessionToolCalled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } @@ -5027,7 +5316,7 @@ export type SessionToolProgress = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5051,7 +5340,7 @@ export type SessionToolSuccess = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5062,12 +5351,9 @@ export type SessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } @@ -5081,39 +5367,58 @@ export type SessionToolFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } -export type SessionRetried = { +export type SessionRetryScheduled = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.retried" + type: "session.retry.scheduled" durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError + } +} + +export type SessionCompactionAdmitted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.admitted" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRef + data: { + sessionID: string + inputID: string } } @@ -5127,12 +5432,14 @@ export type SessionCompactionStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -5146,7 +5453,7 @@ export type SessionCompactionEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5157,6 +5464,27 @@ export type SessionCompactionEnded = { } } +export type SessionCompactionFailed = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + 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 SessionRevertStaged = { id: string created: number @@ -5167,12 +5495,12 @@ export type SessionRevertStaged = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -5186,7 +5514,7 @@ export type SessionRevertCleared = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5204,15 +5532,55 @@ export type SessionRevertCommitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - messageID: string + to: string } } +export type SessionEventDurable = + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionDeleted + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted + | 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 EventLogSynced = { type: "log.synced" aggregateID: string @@ -5238,20 +5606,22 @@ export type ModelVariant = { } } +export type MoneyUsdPerMillionTokens = number + export type ModelCost = { tier?: { type: "context" size: number } - input: number - output: number + input: MoneyUsdPerMillionTokens + output: MoneyUsdPerMillionTokens cache: { - read: number - write: number + read: MoneyUsdPerMillionTokens + write: MoneyUsdPerMillionTokens } } -export type ModelV2Info = { +export type ModelInfo = { id: string modelID: string providerID: string @@ -5442,6 +5812,19 @@ export type McpServer = { integrationID?: string } +export type McpResourceTemplate = { + server: string + name: string + uriTemplate: string + description?: string + mimeType?: string +} + +export type McpResourceCatalog = { + resources: Array + templates: Array +} + export type ProjectCurrent = { id: string directory: string @@ -5496,7 +5879,7 @@ export type FileSystemEntry = { type: "file" | "directory" } -export type CommandV2Info = { +export type CommandInfo = { name: string template: string description?: string @@ -5505,7 +5888,8 @@ export type CommandV2Info = { subtask?: boolean } -export type SkillV2Info = { +export type SkillInfo = { + id: string name: string description?: string slash?: boolean @@ -5589,12 +5973,12 @@ export type SessionCreated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5608,31 +5992,12 @@ export type SessionUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session - } -} - -export type SessionDeleted = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.deleted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - sessionID: string - info: Session + info: SessionV1Info } } @@ -5646,7 +6011,7 @@ export type MessageUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5665,7 +6030,7 @@ export type MessageRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5684,7 +6049,7 @@ export type MessagePartUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5704,7 +6069,7 @@ export type MessagePartRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5714,18 +6079,18 @@ export type MessagePartRemoved = { } } -export type SessionExecutionSettled = { +export type SessionUsageUpdated = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.execution.settled" + type: "session.usage.updated" location?: LocationRef data: { sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown + cost: MoneyUsd + tokens: TokenUsageInfo } } @@ -5740,7 +6105,7 @@ export type SessionTextDelta = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number delta: string } } @@ -5756,7 +6121,7 @@ export type SessionReasoningDelta = { data: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number delta: string } } @@ -5818,7 +6183,7 @@ export type SessionDiff = { location?: LocationRef data: { sessionID: string - diff: Array + diff: Array } } @@ -6382,6 +6747,19 @@ export type McpToolsChanged = { } } +export type McpResourcesChanged = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRef + data: { + server: string + } +} + export type McpStatusChanged = { id: string created: number @@ -6688,7 +7066,7 @@ export type EventSessionCreated = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -6697,7 +7075,7 @@ export type EventSessionUpdated = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -6706,7 +7084,6 @@ export type EventSessionDeleted = { type: "session.deleted" properties: { sessionID: string - info: Session } } @@ -6785,6 +7162,16 @@ export type EventSessionRenamed = { } } +export type EventSessionUsageUpdated = { + id: string + type: "session.usage.updated" + properties: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } +} + export type EventSessionForked = { id: string type: "session.forked" @@ -6815,13 +7202,37 @@ export type EventSessionPromptAdmitted = { } } -export type EventSessionExecutionSettled = { +export type EventSessionExecutionStarted = { id: string - type: "session.execution.settled" + type: "session.execution.started" properties: { sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown + } +} + +export type EventSessionExecutionSucceeded = { + id: string + type: "session.execution.succeeded" + properties: { + sessionID: string + } +} + +export type EventSessionExecutionFailed = { + id: string + type: "session.execution.failed" + properties: { + sessionID: string + error: SessionStructuredError + } +} + +export type EventSessionExecutionInterrupted = { + id: string + type: "session.execution.interrupted" + properties: { + sessionID: string + reason: "user" | "shutdown" | "superseded" } } @@ -6852,6 +7263,7 @@ export type EventSessionSkillActivated = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -6899,17 +7311,9 @@ export type EventSessionStepEnded = { properties: { sessionID: string assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -6921,7 +7325,9 @@ export type EventSessionStepFailed = { properties: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -6931,7 +7337,7 @@ export type EventSessionTextStarted = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } @@ -6941,7 +7347,7 @@ export type EventSessionTextDelta = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number delta: string } } @@ -6952,7 +7358,7 @@ export type EventSessionTextEnded = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -6963,8 +7369,8 @@ export type EventSessionReasoningStarted = { properties: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } @@ -6974,7 +7380,7 @@ export type EventSessionReasoningDelta = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number delta: string } } @@ -6985,9 +7391,9 @@ export type EventSessionReasoningEnded = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } @@ -7031,14 +7437,11 @@ export type EventSessionToolCalled = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } @@ -7067,12 +7470,9 @@ export type EventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } @@ -7083,22 +7483,31 @@ export type EventSessionToolFailed = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } -export type EventSessionRetried = { +export type EventSessionRetryScheduled = { id: string - type: "session.retried" + type: "session.retry.scheduled" properties: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError + } +} + +export type EventSessionCompactionAdmitted = { + id: string + type: "session.compaction.admitted" + properties: { + sessionID: string + inputID: string } } @@ -7108,6 +7517,8 @@ export type EventSessionCompactionStarted = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -7131,12 +7542,23 @@ export type EventSessionCompactionEnded = { } } +export type EventSessionCompactionFailed = { + id: string + type: "session.compaction.failed" + properties: { + sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string + } +} + export type EventSessionRevertStaged = { id: string type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -7153,7 +7575,7 @@ export type EventSessionRevertCommitted = { type: "session.revert.committed" properties: { sessionID: string - messageID: string + to: string } } @@ -7174,7 +7596,7 @@ export type EventSessionDiff = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } @@ -7499,6 +7921,14 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -7684,19 +8114,19 @@ export type CredentialKey = { } } -export type SkillV2DirectorySource = { +export type SkillDirectorySource = { type: "directory" path: string } -export type SkillV2UrlSource = { +export type SkillUrlSource = { type: "url" url: string } -export type SkillV2EmbeddedSource = { +export type SkillEmbeddedSource = { type: "embedded" - skill: SkillV2Info + skill: SkillInfo } export type BadRequestError = { @@ -7707,11 +8137,6 @@ export type BadRequestError = { } } -export type UnauthorizedErrorV2 = { - _tag: "UnauthorizedError" - message: string -} - export type InvalidRequestErrorV2 = { _tag: "InvalidRequestError" message: string @@ -7719,125 +8144,14 @@ export type InvalidRequestErrorV2 = { field?: string | null } -export type LocationInfo2 = { - directory: string - workspaceID?: string - project: { - id: string - directory: string - } -} - -export type ModelRef2 = { - id: string - providerID: string - variant?: string -} - -export type ProviderSettings2 = { - [key: string]: unknown -} - -export type ProviderRequest2 = { - settings: ProviderSettings2 - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } -} - -export type AgentColor2 = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - -export type PermissionV2Effect2 = "allow" | "deny" | "ask" - -export type PermissionV2Rule2 = { - action: string - resource: string - effect: PermissionV2Effect2 -} - -export type PermissionV2Ruleset2 = Array - -export type AgentV2Info2 = { - id: string - model?: ModelRef2 - request: ProviderRequest2 - system?: string - description?: string - mode: "subagent" | "primary" | "all" - hidden: boolean - color?: AgentColor2 - steps?: number - permissions: PermissionV2Ruleset2 -} - -export type PluginInfo2 = { - id: string -} - -export type LocationRef2 = { - directory: string - workspaceID?: string -} - -export type FileDiff2 = { - path: string - status: "added" | "modified" | "deleted" - additions: number - deletions: number - patch: string -} - -export type RevertState2 = { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array -} - -export type SessionV2Info2 = { - id: string - parentID?: string - projectID: string - agent?: string - model?: ModelRef2 - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - time: { - created: number - updated: number - archived?: number - } - title: string - location: LocationRef2 - subpath?: string - revert?: RevertState2 -} - export type SessionsResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null } } -export type InvalidCursorErrorV2 = { - _tag: "InvalidCursorError" - message: string -} - export type InvalidRequestError1 = { _tag: "InvalidRequestError" message: string @@ -7845,113 +8159,12 @@ export type InvalidRequestError1 = { field?: string | null } -export type SessionActiveV2 = { - type: "running" -} - -export type SessionNotFoundErrorV2 = { - _tag: "SessionNotFoundError" - sessionID: string - message: string -} - -export type MessageNotFoundErrorV2 = { - _tag: "MessageNotFoundError" - sessionID: string - messageID: string - message: string -} - -export type PromptMention2 = { - start: number - end: number - text: string -} - -export type PromptInputFileAttachment2 = { - uri: string - name?: string - description?: string - mention?: PromptMention2 -} - -export type PromptAgentAttachment2 = { - name: string - mention?: PromptMention2 -} - -export type PromptInputV2 = { - text: string - files?: Array - agents?: Array -} - -export type PromptBase642 = string - -export type PromptFileSource2 = - | { - type: "inline" - } - | { - type: "uri" - uri: string - } - -export type PromptFileAttachment2 = { - data: PromptBase642 - mime: string - source: PromptFileSource2 - name?: string - description?: string - mention?: PromptMention2 -} - -export type PromptV2 = { - text: string - files?: Array - agents?: Array -} - -export type SessionInputAdmitted2 = { - admittedSeq: number - id: string - sessionID: string - prompt: PromptV2 - delivery: "steer" | "queue" - timeCreated: number - promotedSeq?: number -} - export type ConflictErrorV2 = { _tag: "ConflictError" message: string resource?: string | null } -export type CommandNotFoundErrorV2 = { - _tag: "CommandNotFoundError" - command: string - message: string -} - -export type CommandEvaluationErrorV2 = { - _tag: "CommandEvaluationError" - command: string - message: string -} - -export type SkillNotFoundErrorV2 = { - _tag: "SkillNotFoundError" - skill: string - message: string -} - -export type SessionBusyErrorV2 = { - _tag: "SessionBusyError" - sessionID: string - message: string -} - export type ServiceUnavailableErrorV2 = { _tag: "ServiceUnavailableError" message: string @@ -7964,495 +8177,7 @@ export type UnknownErrorV2 = { ref?: string | null } -export type SessionMessageAgentSelected2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "agent-switched" - agent: string -} - -export type SessionMessageModelSelected2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "model-switched" - model: ModelRef2 - previous?: ModelRef2 -} - -export type SessionMessageUser2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - text: string - files?: Array - agents?: Array - type: "user" -} - -export type SessionMessageSynthetic2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - sessionID: string - text: string - description?: string - type: "synthetic" -} - -export type SessionMessageSystem2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "system" - text: string -} - -export type SessionMessageSkill2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "skill" - name: string - text: string -} - export type ShellV2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - -export type SessionMessageShell2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number - } - type: "shell" - shell: ShellV2 - output?: { - output: string - cursor: number - size: number - truncated: boolean - } -} - -export type SessionMessageAssistantText2 = { - type: "text" - id: string - text: string -} - -export type LlmProviderMetadata2 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionMessageAssistantReasoning2 = { - type: "reasoning" - id: string - text: string - providerMetadata?: LlmProviderMetadata2 - time?: { - created: number - completed?: number - } -} - -export type SessionMessageToolStatePending2 = { - status: "pending" - input: string -} - -export type ToolTextContent2 = { - type: "text" - text: string -} - -export type ToolFileContent2 = { - type: "file" - uri: string - mime: string - name?: string -} - -export type LlmToolContent2 = ToolTextContent2 | ToolFileContent2 - -export type SessionMessageToolStateRunning2 = { - status: "running" - input: { - [key: string]: unknown - } - structured: { - [key: string]: unknown - } - content: Array -} - -export type SessionMessageToolStateCompleted2 = { - status: "completed" - input: { - [key: string]: unknown - } - attachments?: Array - content: Array - outputPaths?: Array - structured: { - [key: string]: unknown - } - result?: unknown -} - -export type SessionErrorUnknown2 = { - type: "unknown" - message: string -} - -export type SessionMessageToolStateError2 = { - status: "error" - input: { - [key: string]: unknown - } - content: Array - structured: { - [key: string]: unknown - } - error: SessionErrorUnknown2 - result?: unknown -} - -export type SessionMessageAssistantTool2 = { - type: "tool" - id: string - name: string - provider?: { - executed: boolean - metadata?: LlmProviderMetadata2 - resultMetadata?: LlmProviderMetadata2 - } - state: - | SessionMessageToolStatePending2 - | SessionMessageToolStateRunning2 - | SessionMessageToolStateCompleted2 - | SessionMessageToolStateError2 - time: { - created: number - ran?: number - completed?: number - pruned?: number - } -} - -export type SessionMessageAssistant2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number - } - type: "assistant" - agent: string - model: ModelRef2 - content: Array - snapshot?: { - start?: string - end?: string - files?: Array - } - finish?: string - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - error?: SessionErrorUnknown2 -} - -export type SessionMessageCompaction2 = { - type: "compaction" - reason: "auto" | "manual" - summary: string - recent: string - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } -} - -export type SessionMessage2 = - | SessionMessageAgentSelected2 - | SessionMessageModelSelected2 - | SessionMessageUser2 - | SessionMessageSynthetic2 - | SessionMessageSystem2 - | SessionMessageSkill2 - | SessionMessageShell2 - | SessionMessageAssistant2 - | SessionMessageCompaction2 - -/** - * Instruction entry key (lowercase alphanumerics plus . _ -) - */ -export type InstructionEntryKey2 = string - -export type InstructionEntryInfo2 = { - key: InstructionEntryKey2 - value: unknown -} - -export type SessionAgentSelected2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.agent.selected" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - agent: string - } -} - -export type SessionModelSelected2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.model.selected" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - model: ModelRef2 - } -} - -export type SessionMoved2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.moved" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - location: LocationRef2 - subpath?: string - } -} - -export type SessionRenamed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.renamed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - title: string - } -} - -export type SessionForked2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.forked" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - parentID: string - from?: string - } -} - -export type SessionPromptPromoted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.prompt.promoted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - inputID: string - } -} - -export type SessionPromptAdmitted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.prompt.admitted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - inputID: string - prompt: PromptV2 - delivery: "steer" | "queue" - } -} - -export type SessionInstructionsUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.instructions.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - text: string - } -} - -export type SessionSynthetic2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.synthetic" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - text: string - description?: string - metadata?: { - [key: string]: unknown - } - } -} - -export type SessionSkillActivated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.skill.activated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - name: string - text: string - } -} - -export type Shell1V2 = { id: string status: "running" | "exited" | "timeout" | "killed" command: string @@ -8470,1280 +8195,21 @@ export type Shell1V2 = { } } -export type SessionShellStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.shell.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - shell: Shell1V2 - } -} - -export type SessionShellEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.shell.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - shell: Shell1V2 - output: { - output: string - cursor: number - size: number - truncated: boolean - } - } -} - -export type SessionStepStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.step.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - agent: string - model: ModelRef2 - snapshot?: string - } -} - -export type SessionStepEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.step.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - snapshot?: string - files?: Array - } -} - -export type SessionStepFailed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.step.failed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - error: SessionErrorUnknown2 - } -} - -export type SessionTextStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.text.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - textID: string - } -} - -export type SessionTextEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.text.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - textID: string - text: string - } -} - -export type LlmProviderMetadata3 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionReasoningStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.reasoning.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata3 - } -} - -export type LlmProviderMetadata4 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionReasoningEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.reasoning.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata4 - } -} - -export type SessionToolInputStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.input.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - name: string - } -} - -export type SessionToolInputEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.input.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - text: string - } -} - -export type LlmProviderMetadata5 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionToolCalled2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.called" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: LlmProviderMetadata5 - } - } -} - -export type SessionToolProgress2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.progress" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - } -} - -export type LlmProviderMetadata6 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionToolSuccess2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.success" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - outputPaths?: Array - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata6 - } - } -} - -export type LlmProviderMetadata7 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionToolFailed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.failed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown2 - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata7 - } - } -} - -export type SessionRetryError2 = { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string - } - responseBody?: string - metadata?: { - [key: string]: string - } -} - -export type SessionRetried2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.retried" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - attempt: number - error: SessionRetryError2 - } -} - -export type SessionCompactionStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.compaction.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - reason: "auto" | "manual" - } -} - -export type SessionCompactionEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.compaction.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - reason: "auto" | "manual" - text: string - recent: string - } -} - -export type SessionRevertStaged2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.revert.staged" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - revert: RevertState2 - } -} - -export type SessionRevertCleared2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.revert.cleared" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - } -} - -export type SessionRevertCommitted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.revert.committed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - messageID: string - } -} - -export type SessionDurableEventV2 = - | SessionAgentSelected2 - | SessionModelSelected2 - | SessionMoved2 - | SessionRenamed2 - | SessionForked2 - | SessionPromptPromoted2 - | SessionPromptAdmitted2 - | SessionInstructionsUpdated2 - | SessionSynthetic2 - | SessionSkillActivated2 - | SessionShellStarted2 - | SessionShellEnded2 - | SessionStepStarted2 - | SessionStepEnded2 - | SessionStepFailed2 - | SessionTextStarted2 - | SessionTextEnded2 - | SessionReasoningStarted2 - | SessionReasoningEnded2 - | SessionToolInputStarted2 - | SessionToolInputEnded2 - | SessionToolCalled2 - | SessionToolProgress2 - | SessionToolSuccess2 - | SessionToolFailed2 - | SessionRetried2 - | SessionCompactionStarted2 - | SessionCompactionEnded2 - | SessionRevertStaged2 - | SessionRevertCleared2 - | SessionRevertCommitted2 - -/** - * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. - */ -export type EventLogSynced2 = { - type: "log.synced" - aggregateID: string - seq?: number -} - -export type SessionLogItemV2 = SessionDurableEventV2 | EventLogSynced2 - -export type SessionLogItemStreamV2 = string - export type SessionMessagesResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null } } -export type ModelCapabilities2 = { - tools: boolean - input: Array - output: Array -} - -export type ModelVariant2 = { - id: string - settings?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - body?: { - [key: string]: unknown - } -} - -export type ModelCost2 = { - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } -} - -export type ModelV2Info2 = { - id: string - modelID: string - providerID: string - family?: string - name: string - package?: string - settings?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - body?: { - [key: string]: unknown - } - capabilities: ModelCapabilities2 - variants: Array - time: { - released: number - } - cost: Array - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - -export type GenerateTextResponseV2 = { - data: { - text: string - } -} - -export type ProviderV2Info2 = { - id: string - integrationID?: string - name: string - disabled?: boolean - package: string - settings?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - body?: { - [key: string]: unknown - } -} - -export type ProviderNotFoundErrorV2 = { - _tag: "ProviderNotFoundError" - providerID: string - message: string -} - -export type IntegrationWhen2 = { - key: string - op: "eq" | "neq" - value: string -} - -export type IntegrationTextPrompt2 = { - type: "text" - key: string - message: string - placeholder?: string - when?: IntegrationWhen2 -} - -export type IntegrationSelectPrompt2 = { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: IntegrationWhen2 -} - -export type IntegrationOAuthMethod2 = { - id: string - type: "oauth" - label: string - prompts?: Array -} - -export type IntegrationKeyMethod2 = { - type: "key" - label?: string -} - -export type IntegrationEnvMethod2 = { - type: "env" - names: Array -} - -export type IntegrationMethod2 = IntegrationOAuthMethod2 | IntegrationKeyMethod2 | IntegrationEnvMethod2 - -export type ConnectionCredentialInfo2 = { - type: "credential" - id: string - label: string -} - -export type ConnectionEnvInfo2 = { - type: "env" - name: string -} - -export type ConnectionInfo2 = ConnectionCredentialInfo2 | ConnectionEnvInfo2 - -export type IntegrationInfo2 = { - id: string - name: string - methods: Array - connections: Array -} - -export type IntegrationAttempt2 = { - attemptID: string - url: string - instructions: string - mode: "auto" | "code" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - -export type IntegrationAttemptStatus2 = - | { - status: "pending" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "complete" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "failed" - message: string - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "expired" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - -export type McpStatusConnected3 = { - status: "connected" -} - -export type McpStatusPending2 = { - status: "pending" -} - -export type McpStatusDisabled3 = { - status: "disabled" -} - -export type McpStatusFailed3 = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth3 = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration3 = { - status: "needs_client_registration" - error: string -} - -export type McpServer2 = { - name: string - status: - | McpStatusConnected3 - | McpStatusPending2 - | McpStatusDisabled3 - | McpStatusFailed3 - | McpStatusNeedsAuth3 - | McpStatusNeedsClientRegistration3 - integrationID?: string -} - -export type ProjectVcs2 = "git" | "hg" - -export type ProjectIcon2 = { - url?: string - override?: string - color?: string -} - -export type ProjectCommands2 = { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string -} - -export type ProjectTime2 = { - created: number - updated: number - initialized?: number -} - -export type ProjectV2 = { - id: string - worktree: string - vcs?: ProjectVcs2 - name?: string - icon?: ProjectIcon2 - commands?: ProjectCommands2 - time: ProjectTime2 - sandboxes: Array -} - -export type ProjectCurrent2 = { - id: string - directory: string -} - -export type ProjectDirectory2 = { - directory: string - strategy?: string -} - -export type ProjectDirectories2 = Array - -export type FormMetadata2 = { - [key: string]: unknown -} - -export type FormWhen2 = { - key: string - op: "eq" | "neq" - value: string | number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" | boolean -} - -export type FormOption2 = { - value: string - label: string - description?: string -} - -export type FormStringField2 = { - 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 FormNumberField3 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "number" - minimum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - default?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" -} - -export type FormIntegerField3 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "integer" - minimum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - default?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" -} - -export type FormBooleanField2 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "boolean" - default?: boolean -} - -export type FormMultiselectField2 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "multiselect" - options: Array - minItems?: number - maxItems?: number - custom?: boolean - default?: Array -} - -export type FormFormInfo2 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata2 - mode: "form" - fields: Array -} - -export type FormUrlInfo2 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata2 - mode: "url" - url: string -} - -export type FormCreatePayload2 = { - id?: string | null - title?: string - metadata?: FormMetadata2 - mode: "form" | "url" - fields?: Array< - FormStringField2 | FormNumberField3 | FormIntegerField3 | FormBooleanField2 | FormMultiselectField2 - > | null - url?: string | null -} - -export type FormNotFoundErrorV2 = { - _tag: "FormNotFoundError" - id: string - message: string -} - -export type FormValue2 = - | string - | number - | "NaN" - | "Infinity" - | "-Infinity" - | "Infinity" - | "-Infinity" - | "NaN" - | boolean - | Array - -export type FormAnswer2 = { - [key: string]: FormValue2 -} - -export type FormState2 = - | { - status: "pending" - } - | { - status: "answered" - answer: FormAnswer2 - } - | { - status: "cancelled" - } - -export type FormReply2 = { - answer: FormAnswer2 -} - -export type FormAlreadySettledErrorV2 = { - _tag: "FormAlreadySettledError" - id: string - message: string -} - -export type FormInvalidAnswerErrorV2 = { - _tag: "FormInvalidAnswerError" - id: string - message: string -} - -export type PermissionV2Source2 = { - type: "tool" - messageID: string - callID: string -} - -export type PermissionV2Request2 = { - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { - [key: string]: unknown - } - source?: PermissionV2Source2 -} - -export type PermissionSavedInfo2 = { - id: string - projectID: string - action: string - resource: string -} - -export type PermissionNotFoundErrorV2 = { - _tag: "PermissionNotFoundError" - requestID: string - message: string -} - -export type PermissionV2Reply2 = "once" | "always" | "reject" - -export type FileSystemEntry2 = { - path: string - type: "file" | "directory" -} - -export type CommandV2Info2 = { - name: string - template: string - description?: string - agent?: string - model?: ModelRef2 - subtask?: boolean -} - -export type SkillV2Info2 = { - name: string - description?: string - slash?: boolean - autoinvoke?: boolean - location: string - content: string -} - -export type ModelsDevRefreshed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "models-dev.refreshed" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type IntegrationUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "integration.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type IntegrationConnectionUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "integration.connection.updated" - location?: LocationRef2 - data: { - integrationID: string - } -} - -export type CatalogUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "catalog.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type AgentUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "agent.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type SnapshotFileDiffV2 = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - -export type PermissionActionV2 = "allow" | "deny" | "ask" - -export type PermissionRuleV2 = { - permission: string - pattern: string - action: PermissionActionV2 -} - -export type PermissionRulesetV2 = Array - -export type SessionV2 = { - 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?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRulesetV2 - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type SessionCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.created" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: SessionV2 - } -} - -export type SessionUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: SessionV2 - } -} - -export type SessionDeleted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.deleted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: SessionV2 - } -} - -export type JsonSchemaV2 = { - [key: string]: unknown -} - export type OutputFormatV2 = | { type: "text" } | { type: "json_schema" - schema: JsonSchemaV2 + schema: JsonSchema retryCount?: number | null | null } @@ -9758,7 +8224,7 @@ export type UserMessageV2 = { summary?: { title?: string | null body?: string | null - diffs: Array + diffs: Array } | null agent: string model: { @@ -9772,14 +8238,6 @@ export type UserMessageV2 = { } | null } -export type ProviderAuthErrorV2 = { - name: "ProviderAuthError" - data: { - providerID: string - message: string - } -} - export type UnknownError1V2 = { name: "UnknownError" data: { @@ -9797,13 +8255,6 @@ export type MessageOutputLengthErrorV2 = { | Array } -export type MessageAbortedErrorV2 = { - name: "MessageAbortedError" - data: { - message: string - } -} - export type StructuredOutputErrorV2 = { name: "StructuredOutputError" data: { @@ -9820,13 +8271,6 @@ export type ContextOverflowErrorV2 = { } } -export type ContentFilterErrorV2 = { - name: "ContentFilterError" - data: { - message: string - } -} - export type ApiErrorV2 = { name: "APIError" data: { @@ -9852,13 +8296,13 @@ export type AssistantMessageV2 = { completed?: number | null } error?: - | ProviderAuthErrorV2 + | ProviderAuthError | UnknownError1V2 | MessageOutputLengthErrorV2 - | MessageAbortedErrorV2 + | MessageAbortedError | StructuredOutputErrorV2 | ContextOverflowErrorV2 - | ContentFilterErrorV2 + | ContentFilterError | ApiErrorV2 | null parentID: string @@ -9887,46 +8331,6 @@ export type AssistantMessageV2 = { finish?: string | null } -export type MessageV2 = UserMessageV2 | AssistantMessageV2 - -export type MessageUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: MessageV2 - } -} - -export type MessageRemoved2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.removed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - messageID: string - } -} - export type TextPartV2 = { id: string sessionID: string @@ -9974,18 +8378,6 @@ export type ReasoningPartV2 = { } } -export type FilePartSourceTextV2 = { - value: string - start: number - end: number -} - -export type FileSourceV2 = { - text: FilePartSourceTextV2 - type: "file" - path: string -} - export type RangeV2 = { start: { line: number @@ -9998,7 +8390,7 @@ export type RangeV2 = { } export type SymbolSourceV2 = { - text: FilePartSourceTextV2 + text: FilePartSourceText type: "symbol" path: string range: RangeV2 @@ -10006,15 +8398,6 @@ export type SymbolSourceV2 = { kind: number } -export type ResourceSourceV2 = { - text: FilePartSourceTextV2 - type: "resource" - clientName: string - uri: string -} - -export type FilePartSourceV2 = FileSourceV2 | SymbolSourceV2 | ResourceSourceV2 - export type FilePartV2 = { id: string sessionID: string @@ -10023,15 +8406,7 @@ export type FilePartV2 = { mime: string filename?: string | null url: string - source?: FilePartSourceV2 | null -} - -export type ToolStatePendingV2 = { - status: "pending" - input: { - [key: string]: unknown - } - raw: string + source?: FilePartSource | null } export type ToolStateRunningV2 = { @@ -10081,8 +8456,6 @@ export type ToolStateErrorV2 = { } } -export type ToolStateV2 = ToolStatePendingV2 | ToolStateRunningV2 | ToolStateCompletedV2 | ToolStateErrorV2 - export type ToolPartV2 = { id: string sessionID: string @@ -10090,7 +8463,7 @@ export type ToolPartV2 = { type: "tool" callID: string tool: string - state: ToolStateV2 + state: ToolState metadata?: { [key: string]: unknown } | null @@ -10176,288 +8549,6 @@ export type CompactionPartV2 = { tail_start_id?: string | null } -export type PartV2 = - | TextPartV2 - | SubtaskPartV2 - | ReasoningPartV2 - | FilePartV2 - | ToolPartV2 - | StepStartPartV2 - | StepFinishPartV2 - | SnapshotPartV2 - | PatchPartV2 - | AgentPartV2 - | RetryPartV2 - | CompactionPartV2 - -export type MessagePartUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.part.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - part: PartV2 - time: number - } -} - -export type MessagePartRemoved2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.part.removed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SessionExecutionSettled2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.execution.settled" - location?: LocationRef2 - data: { - sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown2 - } -} - -export type SessionTextDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.text.delta" - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - textID: string - delta: string - } -} - -export type SessionReasoningDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.reasoning.delta" - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - reasoningID: string - delta: string - } -} - -export type SessionToolInputDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.input.delta" - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - delta: string - } -} - -export type SessionCompactionDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.compaction.delta" - location?: LocationRef2 - data: { - sessionID: string - text: string - } -} - -export type FilesystemChanged2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "filesystem.changed" - location?: LocationRef2 - data: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type ReferenceUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "reference.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type PermissionV2Asked2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "permission.v2.asked" - location?: LocationRef2 - data: { - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { - [key: string]: unknown - } - source?: PermissionV2Source2 - } -} - -export type PermissionV2Replied2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "permission.v2.replied" - location?: LocationRef2 - data: { - sessionID: string - requestID: string - reply: PermissionV2Reply2 - } -} - -export type PluginAdded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "plugin.added" - location?: LocationRef2 - data: { - id: string - } -} - -export type PluginUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "plugin.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type ProjectDirectoriesUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "project.directories.updated" - location?: LocationRef2 - data: { - projectID: string - } -} - -export type CommandUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "command.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type ConfigUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "config.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type SkillUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "skill.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - export type PtyV2 = { id: string title: string @@ -10469,353 +8560,6 @@ export type PtyV2 = { exitCode?: number } -export type PtyCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.created" - location?: LocationRef2 - data: { - info: PtyV2 - } -} - -export type PtyUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.updated" - location?: LocationRef2 - data: { - info: PtyV2 - } -} - -export type PtyExited2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.exited" - location?: LocationRef2 - data: { - id: string - exitCode: number - } -} - -export type PtyDeleted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.deleted" - location?: LocationRef2 - data: { - id: string - } -} - -export type ShellCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "shell.created" - location?: LocationRef2 - data: { - info: Shell1V2 - } -} - -export type ShellExited2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "shell.exited" - location?: LocationRef2 - data: { - id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" - status: "running" | "exited" | "timeout" | "killed" - } -} - -export type ShellDeleted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "shell.deleted" - location?: LocationRef2 - data: { - id: string - } -} - -export type QuestionV2Option2 = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionV2Info2 = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - multiple?: boolean - custom?: boolean -} - -export type QuestionV2Tool2 = { - messageID: string - callID: string -} - -export type QuestionV2Asked2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "question.v2.asked" - location?: LocationRef2 - data: { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: QuestionV2Tool2 - } -} - -export type QuestionV2Answer2 = Array - -export type QuestionV2Replied2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "question.v2.replied" - location?: LocationRef2 - data: { - sessionID: string - requestID: string - answers: Array - } -} - -export type QuestionV2Rejected2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "question.v2.rejected" - location?: LocationRef2 - data: { - sessionID: string - requestID: string - } -} - -export type FormMetadata1 = { - [key: string]: unknown -} - -export type FormWhen12 = { - key: string - op: "eq" | "neq" - value: string | number | "NaN" | "Infinity" | "-Infinity" | boolean -} - -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 FormNumberField12 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "number" - minimum?: number | "NaN" | "Infinity" | "-Infinity" - maximum?: number | "NaN" | "Infinity" | "-Infinity" - default?: number | "NaN" | "Infinity" | "-Infinity" -} - -export type FormIntegerField12 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "integer" - minimum?: number | "NaN" | "Infinity" | "-Infinity" - maximum?: number | "NaN" | "Infinity" | "-Infinity" - default?: number | "NaN" | "Infinity" | "-Infinity" -} - -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 FormFormInfo1 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata1 - mode: "form" - fields: Array -} - -export type FormUrlInfo1 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata1 - mode: "url" - url: string -} - -export type FormCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "form.created" - location?: LocationRef2 - data: { - form: FormFormInfo1 | FormUrlInfo1 - } -} - -export type FormValue12 = string | number | "NaN" | "Infinity" | "-Infinity" | boolean | Array - -export type FormAnswer1 = { - [key: string]: FormValue12 -} - -export type FormReplied2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "form.replied" - location?: LocationRef2 - data: { - id: string - sessionID: string - answer: FormAnswer1 - } -} - -export type FormCancelled2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "form.cancelled" - location?: LocationRef2 - data: { - id: string - sessionID: string - } -} - -export type TodoV2 = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type TodoUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "todo.updated" - location?: LocationRef2 - data: { - sessionID: string - todos: Array - } -} - export type SessionStatusV2 = | { type: "idle" @@ -10845,47 +8589,2139 @@ export type SessionStatusV22 = { [key: string]: unknown } type: "session.status" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string status: SessionStatusV2 } } -export type SessionIdle2 = { +export type QuestionInfoV2 = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean | null + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean | null +} + +export type QuestionToolV2 = { + messageID: string + callID: string +} + +export type V2EventV2 = + | ModelsDevRefreshedV2 + | IntegrationUpdatedV2 + | IntegrationConnectionUpdatedV2 + | CatalogUpdatedV2 + | AgentUpdatedV2 + | SessionCreatedV2 + | SessionUpdatedV2 + | SessionDeleted1 + | MessageUpdatedV2 + | MessageRemovedV2 + | MessagePartUpdatedV2 + | MessagePartRemovedV2 + | SessionAgentSelectedV2 + | SessionModelSelectedV2 + | SessionMovedV2 + | SessionRenamedV2 + | SessionUsageUpdatedV2 + | SessionDeletedV2 + | SessionForkedV2 + | SessionPromptPromotedV2 + | SessionPromptAdmittedV2 + | SessionExecutionStartedV2 + | SessionExecutionSucceededV2 + | SessionExecutionFailedV2 + | SessionExecutionInterruptedV2 + | SessionInstructionsUpdatedV2 + | SessionSyntheticV2 + | SessionSkillActivatedV2 + | SessionShellStartedV2 + | SessionShellEndedV2 + | SessionStepStartedV2 + | SessionStepEndedV2 + | SessionStepFailedV2 + | SessionTextStartedV2 + | SessionTextDeltaV2 + | SessionTextEndedV2 + | SessionReasoningStartedV2 + | SessionReasoningDeltaV2 + | SessionReasoningEndedV2 + | SessionToolInputStartedV2 + | SessionToolInputDeltaV2 + | SessionToolInputEndedV2 + | SessionToolCalledV2 + | SessionToolProgressV2 + | SessionToolSuccessV2 + | SessionToolFailedV2 + | SessionRetryScheduledV2 + | SessionCompactionAdmittedV2 + | SessionCompactionStartedV2 + | SessionCompactionDeltaV2 + | SessionCompactionEndedV2 + | SessionCompactionFailedV2 + | SessionRevertStagedV2 + | SessionRevertClearedV2 + | SessionRevertCommittedV2 + | FilesystemChangedV2 + | ReferenceUpdatedV2 + | PermissionV2AskedV2 + | PermissionV2RepliedV2 + | PluginAddedV2 + | PluginUpdatedV2 + | ProjectDirectoriesUpdatedV2 + | CommandUpdatedV2 + | ConfigUpdatedV2 + | SkillUpdatedV2 + | PtyCreatedV2 + | PtyUpdatedV2 + | PtyExitedV2 + | PtyDeletedV2 + | ShellCreatedV2 + | ShellExitedV2 + | ShellDeletedV2 + | QuestionV2AskedV2 + | QuestionV2RepliedV2 + | QuestionV2RejectedV2 + | FormCreatedV2 + | FormRepliedV2 + | FormCancelledV2 + | TodoUpdatedV2 + | SessionStatusV22 + | SessionIdleV2 + | TuiPromptAppendV2 + | TuiCommandExecuteV2 + | TuiToastShowV2 + | TuiSessionSelectV2 + | InstallationUpdatedV2 + | InstallationUpdateAvailableV2 + | VcsBranchUpdatedV2 + | McpStatusChangedV2 + | McpResourcesChangedV2 + | PermissionAskedV2 + | PermissionRepliedV2 + | QuestionAskedV2 + | QuestionRepliedV2 + | QuestionRejectedV2 + | SessionErrorV2 + | V2EventServerConnected + +export type ProjectCopyErrorV2 = { + name: "ProjectCopyError" + data: { + message: string + forceRequired?: boolean | null + } +} + +export type LocationInfoV2 = { + directory: string + workspaceID?: string + project: { + id: string + directory: string + } +} + +export type AgentColorV2 = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + +export type AgentInfoV2 = { + id: string + name: string + model?: ModelRef + request: ProviderRequest + system?: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean + color?: AgentColorV2 + steps?: number + permissions: PermissionV2Ruleset +} + +export type LocationRefV2 = { + directory: string + workspaceID?: string +} + +export type FileDiffInfoV2 = { + file: string + patch: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +export type SessionRevertV2 = { + messageID: string + partID?: string + snapshot?: string + files?: Array +} + +export type SessionInfoV2 = { + 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: LocationRefV2 + subpath?: string + revert?: SessionRevertV2 +} + +export type PromptBase64V2 = string + +export type SessionInputAdmittedV2 = { + admittedSeq: number + id: string + sessionID: string + prompt: Prompt + delivery: "steer" | "queue" + timeCreated: number + promotedSeq?: number +} + +export type SessionInputCompactionV2 = { + type: "compaction" + admittedSeq: number + id: string + sessionID: string + timeCreated: number + handledSeq?: number +} + +export type SessionMessageAgentSelectedV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSelectedV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: ModelRef + previous?: ModelRef +} + +export type SessionMessageUserV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSyntheticV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + description?: string + type: "synthetic" +} + +export type SessionMessageSystemV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "system" + text: string +} + +export type SessionMessageSkillV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "skill" + skill: string + name: string + text: string +} + +export type SessionMessageShellV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + output?: { + output: string + cursor: number + size: number + truncated: boolean + } +} + +export type SessionMessageAssistantRetryV2 = { + attempt: number + at: number + error: SessionStructuredError +} + +export type SessionMessageAssistantV2 = { + id: string + metadata?: { + [key: string]: unknown + } + 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?: SessionMessageAssistantRetryV2 +} + +export type SessionMessageCompactionRunningV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionCompletedV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionFailedV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError +} + +/** + * Instruction entry key (lowercase alphanumerics plus . _ -) + */ +export type InstructionEntryKeyV2 = string + +export type SessionAgentSelectedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.agent.selected" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + agent: string + } +} + +export type SessionModelSelectedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.model.selected" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + model: ModelRef + } +} + +export type SessionMovedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.moved" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + location: LocationRefV2 + subpath?: string + } +} + +export type SessionRenamedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.renamed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + title: string + } +} + +export type SessionDeletedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable: { + aggregateID: string + seq: number + version: 2 + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionForkedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.forked" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + parentID: string + from?: string + } +} + +export type SessionPromptPromotedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.promoted" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + inputID: string + } +} + +export type SessionPromptAdmittedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.admitted" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + inputID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionExecutionStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionExecutionSucceededV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.succeeded" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionExecutionFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.failed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + error: SessionStructuredError + } +} + +export type SessionExecutionInterruptedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.interrupted" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "user" | "shutdown" | "superseded" + } +} + +export type SessionInstructionsUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.instructions.updated" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + text: string + } +} + +export type SessionSyntheticV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.synthetic" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + text: string + description?: string + metadata?: { + [key: string]: unknown + } + } +} + +export type SessionSkillActivatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.skill.activated" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + id: string + name: string + text: string + } +} + +export type SessionShellStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.shell.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + shell: ShellV2 + } +} + +export type SessionShellEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.shell.ended" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + shell: ShellV2 + output: { + output: string + cursor: number + size: number + truncated: boolean + } + } +} + +export type SessionStepStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionStepEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.ended" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: MoneyUsd + tokens: TokenUsageInfo + snapshot?: string + files?: Array + } +} + +export type SessionStepFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.failed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo + } +} + +export type SessionTextStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.text.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + } +} + +export type SessionTextEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.text.ended" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + text: string + } +} + +export type SessionMessageProviderState3 = { + [key: string]: unknown +} + +export type SessionReasoningStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + state?: SessionMessageProviderState3 + } +} + +export type SessionMessageProviderState4 = { + [key: string]: unknown +} + +export type SessionReasoningEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.ended" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + text: string + state?: SessionMessageProviderState4 + } +} + +export type SessionToolInputStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionToolInputEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.ended" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionMessageProviderState5 = { + [key: string]: unknown +} + +export type SessionToolCalledV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.called" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + input: { + [key: string]: unknown + } + executed: boolean + state?: SessionMessageProviderState5 + } +} + +export type SessionToolProgressV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.progress" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionMessageProviderState6 = { + [key: string]: unknown +} + +export type SessionToolSuccessV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.success" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + result?: unknown + executed: boolean + resultState?: SessionMessageProviderState6 + } +} + +export type SessionMessageProviderState7 = { + [key: string]: unknown +} + +export type SessionToolFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.failed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + error: SessionStructuredError + result?: unknown + executed: boolean + resultState?: SessionMessageProviderState7 + } +} + +export type SessionRetryScheduledV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.retry.scheduled" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + attempt: number + at: number + error: SessionStructuredError + } +} + +export type SessionCompactionAdmittedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.admitted" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + inputID: string + } +} + +export type SessionCompactionStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.started" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "auto" | "manual" + recent: string + inputID?: string + } +} + +export type SessionCompactionEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.ended" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type SessionCompactionFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.failed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string + } +} + +export type SessionRevertStagedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.revert.staged" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + revert: SessionRevertV2 + } +} + +export type SessionRevertClearedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.revert.cleared" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionRevertCommittedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.revert.committed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + to: string + } +} + +/** + * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. + */ +export type EventLogSyncedV2 = { + type: "log.synced" + aggregateID: string + seq?: number +} + +export type McpResourceV2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + +export type McpResourceCatalogV2 = { + resources: Array + templates: Array +} + +export type ProjectTimeV2 = { + created: number + updated: number + initialized?: number +} + +export type FormWhenV2 = { + key: string + op: "eq" | "neq" + value: string | number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" | boolean +} + +export type FormStringFieldV2 = { + 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 FormMultiselectFieldV2 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "multiselect" + options: Array + minItems?: number + maxItems?: number + custom?: boolean + default?: Array +} + +export type FormFormInfoV2 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "form" + fields: Array +} + +export type FormUrlInfoV2 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "url" + url: string +} + +export type FormCreatePayloadV2 = { + id?: string | null + title?: string + metadata?: FormMetadata + mode: "form" | "url" + fields?: Array< + FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2 + > | null + url?: string | null +} + +export type FormValueV2 = + | string + | number + | "NaN" + | "Infinity" + | "-Infinity" + | "Infinity" + | "-Infinity" + | "NaN" + | boolean + | Array + +export type PermissionV2SourceV2 = { + type: "tool" + messageID: string + callID: string +} + +export type PermissionV2RequestV2 = { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2SourceV2 +} + +export type ModelsDevRefreshedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "models-dev.refreshed" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type IntegrationUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "integration.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type IntegrationConnectionUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "integration.connection.updated" + location?: LocationRefV2 + data: { + integrationID: string + } +} + +export type CatalogUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "catalog.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type AgentUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "agent.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type SessionV1InfoV2 = { + 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?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type SessionCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.created" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + info: SessionV1InfoV2 + } +} + +export type SessionUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.updated" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + info: SessionV1InfoV2 + } +} + +export type SessionDeleted1 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + info: SessionV1InfoV2 + } +} + +export type MessageUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.updated" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + info: Message + } +} + +export type MessageRemovedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.removed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + messageID: string + } +} + +export type MessagePartUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.part.updated" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + part: Part + time: number + } +} + +export type MessagePartRemovedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.part.removed" + durable: { + aggregateID: string + seq: number + version: 1 + } + location?: LocationRefV2 + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SessionUsageUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.usage.updated" + location?: LocationRefV2 + data: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } +} + +export type SessionTextDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.text.delta" + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + delta: string + } +} + +export type SessionReasoningDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.delta" + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + delta: string + } +} + +export type SessionToolInputDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.delta" + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type SessionCompactionDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.delta" + location?: LocationRefV2 + data: { + sessionID: string + text: string + } +} + +export type FilesystemChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "filesystem.changed" + location?: LocationRefV2 + data: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type ReferenceUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "reference.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type PermissionV2AskedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "permission.v2.asked" + location?: LocationRefV2 + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2SourceV2 + } +} + +export type PermissionV2RepliedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "permission.v2.replied" + location?: LocationRefV2 + data: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type PluginAddedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.added" + location?: LocationRefV2 + data: { + id: string + } +} + +export type PluginUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type ProjectDirectoriesUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "project.directories.updated" + location?: LocationRefV2 + data: { + projectID: string + } +} + +export type CommandUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "command.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type ConfigUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type SkillUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "skill.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type PtyCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.created" + location?: LocationRefV2 + data: { + info: PtyV2 + } +} + +export type PtyUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.updated" + location?: LocationRefV2 + data: { + info: PtyV2 + } +} + +export type PtyExitedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.exited" + location?: LocationRefV2 + data: { + id: string + exitCode: number + } +} + +export type PtyDeletedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.deleted" + location?: LocationRefV2 + data: { + id: string + } +} + +export type ShellCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "shell.created" + location?: LocationRefV2 + data: { + info: ShellV2 + } +} + +export type ShellExitedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "shell.exited" + location?: LocationRefV2 + data: { + id: string + exit?: number | "NaN" | "Infinity" | "-Infinity" + status: "running" | "exited" | "timeout" | "killed" + } +} + +export type ShellDeletedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "shell.deleted" + location?: LocationRefV2 + data: { + id: string + } +} + +export type QuestionV2AskedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "question.v2.asked" + location?: LocationRefV2 + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type QuestionV2RepliedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "question.v2.replied" + location?: LocationRefV2 + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionV2RejectedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "question.v2.rejected" + location?: LocationRefV2 + data: { + sessionID: string + requestID: string + } +} + +export type FormMetadata1 = { + [key: string]: unknown +} + +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 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 FormFormInfo1 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata1 + mode: "form" + fields: Array +} + +export type FormUrlInfo1 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata1 + mode: "url" + url: string +} + +export type FormCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "form.created" + location?: LocationRefV2 + data: { + form: FormFormInfo1 | FormUrlInfo1 + } +} + +export type FormAnswer1 = { + [key: string]: FormValue1 +} + +export type FormRepliedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "form.replied" + location?: LocationRefV2 + data: { + id: string + sessionID: string + answer: FormAnswer1 + } +} + +export type FormCancelledV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "form.cancelled" + location?: LocationRefV2 + data: { + id: string + sessionID: string + } +} + +export type TodoUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "todo.updated" + location?: LocationRefV2 + data: { + sessionID: string + todos: Array + } +} + +export type SessionIdleV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "session.idle" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string } } -export type TuiPromptAppend2 = { +export type TuiPromptAppendV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.prompt.append" - location?: LocationRef2 + location?: LocationRefV2 data: { text: string } } -export type TuiCommandExecute2 = { +export type TuiCommandExecuteV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.command.execute" - location?: LocationRef2 + location?: LocationRefV2 data: { command: | "session.list" @@ -10909,14 +10745,14 @@ export type TuiCommandExecute2 = { } } -export type TuiToastShow2 = { +export type TuiToastShowV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.toast.show" - location?: LocationRef2 + location?: LocationRefV2 data: { title?: string message: string @@ -10925,14 +10761,14 @@ export type TuiToastShow2 = { } } -export type TuiSessionSelect2 = { +export type TuiSessionSelectV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.session.select" - location?: LocationRef2 + location?: LocationRefV2 data: { /** * Session ID to navigate to @@ -10941,66 +10777,79 @@ export type TuiSessionSelect2 = { } } -export type InstallationUpdated2 = { +export type InstallationUpdatedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "installation.updated" - location?: LocationRef2 + location?: LocationRefV2 data: { version: string } } -export type InstallationUpdateAvailable2 = { +export type InstallationUpdateAvailableV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "installation.update-available" - location?: LocationRef2 + location?: LocationRefV2 data: { version: string } } -export type VcsBranchUpdated2 = { +export type VcsBranchUpdatedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "vcs.branch.updated" - location?: LocationRef2 + location?: LocationRefV2 data: { branch?: string } } -export type McpStatusChanged2 = { +export type McpStatusChangedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "mcp.status.changed" - location?: LocationRef2 + location?: LocationRefV2 data: { server: string } } -export type PermissionAsked2 = { +export type McpResourcesChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRefV2 + data: { + server: string + } +} + +export type PermissionAskedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "permission.asked" - location?: LocationRef2 + location?: LocationRefV2 data: { id: string sessionID: string @@ -11017,14 +10866,14 @@ export type PermissionAsked2 = { } } -export type PermissionReplied2 = { +export type PermissionRepliedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "permission.replied" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string requestID: string @@ -11032,53 +10881,14 @@ export type PermissionReplied2 = { } } -export type QuestionOptionV2 = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfoV2 = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean | null - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean | null -} - -export type QuestionToolV2 = { - messageID: string - callID: string -} - -export type QuestionAsked2 = { +export type QuestionAskedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "question.asked" - location?: LocationRef2 + location?: LocationRefV2 data: { id: string sessionID: string @@ -11090,8 +10900,6 @@ export type QuestionAsked2 = { } } -export type QuestionAnswerV2 = Array - export type QuestionRepliedV2 = { id: string created: number @@ -11099,11 +10907,11 @@ export type QuestionRepliedV2 = { [key: string]: unknown } type: "question.replied" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string requestID: string - answers: Array + answers: Array } } @@ -11114,31 +10922,31 @@ export type QuestionRejectedV2 = { [key: string]: unknown } type: "question.rejected" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string requestID: string } } -export type SessionError2 = { +export type SessionErrorV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "session.error" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID?: string | null error?: - | ProviderAuthErrorV2 + | ProviderAuthError | UnknownError1V2 | MessageOutputLengthErrorV2 - | MessageAbortedErrorV2 + | MessageAbortedError | StructuredOutputErrorV2 | ContextOverflowErrorV2 - | ContentFilterErrorV2 + | ContentFilterError | ApiErrorV2 | null } @@ -11149,7 +10957,7 @@ export type V2EventServerConnected = { metadata?: { [key: string]: unknown } | null - location?: LocationRef2 | null + location?: LocationRefV2 | null type: "server.connected" data: | { @@ -11158,179 +10966,19 @@ export type V2EventServerConnected = { | Array } -export type V2EventV2 = - | ModelsDevRefreshed2 - | IntegrationUpdated2 - | IntegrationConnectionUpdated2 - | CatalogUpdated2 - | AgentUpdated2 - | SessionCreated2 - | SessionUpdated2 - | SessionDeleted2 - | MessageUpdated2 - | MessageRemoved2 - | MessagePartUpdated2 - | MessagePartRemoved2 - | SessionAgentSelected2 - | SessionModelSelected2 - | SessionMoved2 - | SessionRenamed2 - | SessionForked2 - | SessionPromptPromoted2 - | SessionPromptAdmitted2 - | SessionExecutionSettled2 - | SessionInstructionsUpdated2 - | SessionSynthetic2 - | SessionSkillActivated2 - | SessionShellStarted2 - | SessionShellEnded2 - | SessionStepStarted2 - | SessionStepEnded2 - | SessionStepFailed2 - | SessionTextStarted2 - | SessionTextDelta2 - | SessionTextEnded2 - | SessionReasoningStarted2 - | SessionReasoningDelta2 - | SessionReasoningEnded2 - | SessionToolInputStarted2 - | SessionToolInputDelta2 - | SessionToolInputEnded2 - | SessionToolCalled2 - | SessionToolProgress2 - | SessionToolSuccess2 - | SessionToolFailed2 - | SessionRetried2 - | SessionCompactionStarted2 - | SessionCompactionDelta2 - | SessionCompactionEnded2 - | SessionRevertStaged2 - | SessionRevertCleared2 - | SessionRevertCommitted2 - | FilesystemChanged2 - | ReferenceUpdated2 - | PermissionV2Asked2 - | PermissionV2Replied2 - | PluginAdded2 - | PluginUpdated2 - | ProjectDirectoriesUpdated2 - | CommandUpdated2 - | ConfigUpdated2 - | SkillUpdated2 - | PtyCreated2 - | PtyUpdated2 - | PtyExited2 - | PtyDeleted2 - | ShellCreated2 - | ShellExited2 - | ShellDeleted2 - | QuestionV2Asked2 - | QuestionV2Replied2 - | QuestionV2Rejected2 - | FormCreated2 - | FormReplied2 - | FormCancelled2 - | TodoUpdated2 - | SessionStatusV22 - | SessionIdle2 - | TuiPromptAppend2 - | TuiCommandExecute2 - | TuiToastShow2 - | TuiSessionSelect2 - | InstallationUpdated2 - | InstallationUpdateAvailable2 - | VcsBranchUpdated2 - | McpStatusChanged2 - | PermissionAsked2 - | PermissionReplied2 - | QuestionAsked2 - | QuestionRepliedV2 - | QuestionRejectedV2 - | SessionError2 - | V2EventServerConnected - -export type V2EventStreamV2 = string - -export type PtyNotFoundErrorV2 = { - _tag: "PtyNotFoundError" - ptyID: string - message: string -} - -export type PtyTicketConnectToken2 = { +export type PtyTicketConnectTokenV2 = { ticket: string expires_in: number } -export type ForbiddenErrorV2 = { - _tag: "ForbiddenError" - message: string -} - -export type ShellNotFoundErrorV2 = { - _tag: "ShellNotFoundError" - id: string - message: string -} - -export type QuestionV2Request2 = { +export type QuestionV2RequestV2 = { id: string sessionID: string /** * Questions to ask */ - questions: Array - tool?: QuestionV2Tool2 -} - -export type QuestionV2Reply2 = { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array -} - -export type QuestionNotFoundErrorV2 = { - _tag: "QuestionNotFoundError" - requestID: string - message: string -} - -export type ReferenceLocalSource2 = { - type: "local" - path: string - description?: string - hidden?: boolean -} - -export type ReferenceGitSource2 = { - type: "git" - repository: string - branch?: string - description?: string - hidden?: boolean -} - -export type ReferenceSource2 = ReferenceLocalSource2 | ReferenceGitSource2 - -export type ReferenceInfo2 = { - name: string - path: string - description?: string - hidden?: boolean - source: ReferenceSource2 -} - -export type ProjectCopyCopy2 = { - directory: string -} - -export type ProjectCopyErrorV2 = { - name: "ProjectCopyError" - data: { - message: string - forceRequired?: boolean | null - } + questions: Array + tool?: QuestionV2Tool } export type VcsFileStatusV2 = { @@ -11340,8 +10988,6 @@ export type VcsFileStatusV2 = { status: "added" | "deleted" | "modified" } -export type VcsMode2 = "working" | "branch" - export type AuthRemoveData = { body?: never path: { @@ -13995,7 +13641,7 @@ export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array + 200: Array } export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] @@ -15490,7 +15136,7 @@ export type V2HealthGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors] @@ -15528,7 +15174,7 @@ export type V2LocationGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors] @@ -15537,7 +15183,7 @@ export type V2LocationGetResponses = { /** * Location.Info */ - 200: LocationInfo2 + 200: LocationInfoV2 } export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses] @@ -15562,7 +15208,7 @@ export type V2AgentListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2AgentListError = V2AgentListErrors[keyof V2AgentListErrors] @@ -15572,8 +15218,8 @@ export type V2AgentListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -15599,7 +15245,7 @@ export type V2PluginListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PluginListError = V2PluginListErrors[keyof V2PluginListErrors] @@ -15609,8 +15255,8 @@ export type V2PluginListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -15643,11 +15289,11 @@ export type V2SessionListErrors = { /** * InvalidCursorError | InvalidRequestError */ - 400: InvalidCursorErrorV2 | InvalidRequestError1 | InvalidRequestErrorV2 + 400: InvalidCursorError | InvalidRequestError1 | InvalidRequestErrorV2 /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] @@ -15665,8 +15311,8 @@ export type V2SessionCreateData = { body: { id?: string | null agent?: string | null - model?: ModelRef2 | null - location?: LocationRef2 | null + model?: ModelRef | null + location?: LocationRefV2 | null } path?: never query?: never @@ -15681,7 +15327,7 @@ export type V2SessionCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors] @@ -15691,7 +15337,7 @@ export type V2SessionCreateResponses = { * Success */ 200: { - data: SessionV2Info2 + data: SessionInfoV2 } } @@ -15712,7 +15358,7 @@ export type V2SessionActiveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors] @@ -15723,13 +15369,48 @@ export type V2SessionActiveResponses = { */ 200: { data: { - [key: string]: unknown | SessionActiveV2 + [key: string]: unknown | SessionActive } } } export type V2SessionActiveResponse = V2SessionActiveResponses[keyof V2SessionActiveResponses] +export type V2SessionRemoveData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}" +} + +export type V2SessionRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionRemoveError = V2SessionRemoveErrors[keyof V2SessionRemoveErrors] + +export type V2SessionRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRemoveResponse = V2SessionRemoveResponses[keyof V2SessionRemoveResponses] + export type V2SessionGetData = { body?: never path: { @@ -15747,11 +15428,11 @@ export type V2SessionGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors] @@ -15761,7 +15442,7 @@ export type V2SessionGetResponses = { * Success */ 200: { - data: SessionV2Info2 + data: SessionInfoV2 } } @@ -15786,11 +15467,11 @@ export type V2SessionForkErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | MessageNotFoundError */ - 404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: MessageNotFoundError | SessionNotFoundError } export type V2SessionForkError = V2SessionForkErrors[keyof V2SessionForkErrors] @@ -15800,7 +15481,7 @@ export type V2SessionForkResponses = { * Success */ 200: { - data: SessionV2Info2 + data: SessionInfoV2 } } @@ -15825,11 +15506,11 @@ export type V2SessionSwitchAgentErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] @@ -15845,7 +15526,7 @@ export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V export type V2SessionSwitchModelData = { body: { - model: ModelRef2 + model: ModelRef } path: { sessionID: string @@ -15862,11 +15543,11 @@ export type V2SessionSwitchModelErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] @@ -15899,11 +15580,11 @@ export type V2SessionRenameErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionRenameError = V2SessionRenameErrors[keyof V2SessionRenameErrors] @@ -15917,10 +15598,50 @@ export type V2SessionRenameResponses = { export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses] +export type V2SessionMoveData = { + body: { + destination: { + directory: string + } + moveChanges?: boolean | null + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/move" +} + +export type V2SessionMoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionMoveError = V2SessionMoveErrors[keyof V2SessionMoveErrors] + +export type V2SessionMoveResponses = { + /** + * + */ + 204: void +} + +export type V2SessionMoveResponse = V2SessionMoveResponses[keyof V2SessionMoveResponses] + export type V2SessionPromptData = { body: { id?: string | null - prompt: PromptInputV2 + prompt: PromptInput delivery?: "steer" | "queue" | null resume?: boolean | null } @@ -15939,11 +15660,11 @@ export type V2SessionPromptErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * ConflictError */ @@ -15957,7 +15678,7 @@ export type V2SessionPromptResponses = { * Success */ 200: { - data: SessionInputAdmitted2 + data: SessionInputAdmittedV2 } } @@ -15969,9 +15690,9 @@ export type V2SessionCommandData = { command: string arguments?: string | null agent?: string | null - model?: ModelRef2 | null - files?: Array - agents?: Array + model?: ModelRef | null + files?: Array + agents?: Array delivery?: "steer" | "queue" | null resume?: boolean | null } @@ -15990,11 +15711,11 @@ export type V2SessionCommandErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | CommandNotFoundError */ - 404: CommandNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: CommandNotFoundError | SessionNotFoundError /** * ConflictError */ @@ -16002,7 +15723,7 @@ export type V2SessionCommandErrors = { /** * CommandEvaluationError */ - 500: CommandEvaluationErrorV2 + 500: CommandEvaluationError } export type V2SessionCommandError = V2SessionCommandErrors[keyof V2SessionCommandErrors] @@ -16012,7 +15733,7 @@ export type V2SessionCommandResponses = { * Success */ 200: { - data: SessionInputAdmitted2 + data: SessionInputAdmittedV2 } } @@ -16039,11 +15760,11 @@ export type V2SessionSkillErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | SkillNotFoundError */ - 404: SkillNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: SkillNotFoundError | SessionNotFoundError } export type V2SessionSkillError = V2SessionSkillErrors[keyof V2SessionSkillErrors] @@ -16064,6 +15785,7 @@ export type V2SessionSyntheticData = { metadata?: { [key: string]: unknown } + resume?: boolean | null } path: { sessionID: string @@ -16080,11 +15802,11 @@ export type V2SessionSyntheticErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors] @@ -16118,11 +15840,11 @@ export type V2SessionShellErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors] @@ -16137,7 +15859,9 @@ export type V2SessionShellResponses = { export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses] export type V2SessionCompactData = { - body?: never + body: { + id?: string | null + } path: { sessionID: string } @@ -16153,32 +15877,26 @@ export type V2SessionCompactErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** - * SessionBusyError + * ConflictError */ - 409: SessionBusyErrorV2 - /** - * UnknownError - */ - 500: UnknownErrorV2 - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableErrorV2 + 409: ConflictErrorV2 } export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] export type V2SessionCompactResponses = { /** - * + * Success */ - 204: void + 200: { + data: SessionInputCompactionV2 + } } export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] @@ -16200,11 +15918,11 @@ export type V2SessionWaitErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * ServiceUnavailableError */ @@ -16242,15 +15960,15 @@ export type V2SessionRevertStageErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * MessageNotFoundError | SessionNotFoundError */ - 404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: MessageNotFoundError | SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError /** * UnknownError */ @@ -16264,7 +15982,7 @@ export type V2SessionRevertStageResponses = { * Success */ 200: { - data: RevertState2 + data: SessionRevertV2 } } @@ -16287,15 +16005,15 @@ export type V2SessionRevertClearErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError /** * UnknownError */ @@ -16330,15 +16048,15 @@ export type V2SessionRevertCommitErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError } export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors] @@ -16369,11 +16087,11 @@ export type V2SessionContextErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * UnknownError */ @@ -16387,7 +16105,7 @@ export type V2SessionContextResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16410,11 +16128,11 @@ export type V2SessionInstructionsEntryListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInstructionsEntryListError = @@ -16425,7 +16143,7 @@ export type V2SessionInstructionsEntryListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16436,7 +16154,7 @@ export type V2SessionInstructionsEntryRemoveData = { body?: never path: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 } query?: never url: "/api/session/{sessionID}/instructions/entries/{key}" @@ -16450,11 +16168,11 @@ export type V2SessionInstructionsEntryRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInstructionsEntryRemoveError = @@ -16476,7 +16194,7 @@ export type V2SessionInstructionsEntryPutData = { } path: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 } query?: never url: "/api/session/{sessionID}/instructions/entries/{key}" @@ -16490,11 +16208,11 @@ export type V2SessionInstructionsEntryPutErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInstructionsEntryPutError = @@ -16530,11 +16248,11 @@ export type V2SessionLogErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionLogError = V2SessionLogErrors[keyof V2SessionLogErrors] @@ -16546,7 +16264,7 @@ export type V2SessionLogResponses = { 200: { id: string | null event: string - data: SessionLogItemStreamV2 + data: SessionLogItemStream } } @@ -16569,11 +16287,11 @@ export type V2SessionInterruptErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors] @@ -16604,11 +16322,11 @@ export type V2SessionBackgroundErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionBackgroundError = V2SessionBackgroundErrors[keyof V2SessionBackgroundErrors] @@ -16640,11 +16358,11 @@ export type V2SessionMessageErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | MessageNotFoundError */ - 404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: MessageNotFoundError | SessionNotFoundError } export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors] @@ -16654,13 +16372,13 @@ export type V2SessionMessageResponses = { * Success */ 200: { - data: SessionMessage2 + data: SessionMessageInfo } } export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] -export type V2SessionMessagesData = { +export type V2MessageListData = { body?: never path: { sessionID: string @@ -16679,35 +16397,35 @@ export type V2SessionMessagesData = { url: "/api/session/{sessionID}/message" } -export type V2SessionMessagesErrors = { +export type V2MessageListErrors = { /** * InvalidCursorError | InvalidRequestError */ - 400: InvalidCursorErrorV2 | InvalidRequestErrorV2 + 400: InvalidCursorError | InvalidRequestErrorV2 /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * UnknownError */ 500: UnknownErrorV2 } -export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] +export type V2MessageListError = V2MessageListErrors[keyof V2MessageListErrors] -export type V2SessionMessagesResponses = { +export type V2MessageListResponses = { /** * SessionMessagesResponse */ 200: SessionMessagesResponseV2 } -export type V2SessionMessagesResponse = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] +export type V2MessageListResponse = V2MessageListResponses[keyof V2MessageListResponses] export type V2ModelListData = { body?: never @@ -16729,7 +16447,7 @@ export type V2ModelListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16743,8 +16461,8 @@ export type V2ModelListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -16770,7 +16488,7 @@ export type V2ModelDefaultErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16784,8 +16502,8 @@ export type V2ModelDefaultResponses = { * Success */ 200: { - location: LocationInfo2 - data: ModelV2Info2 | null + location: LocationInfoV2 + data: ModelInfo | null } } @@ -16794,7 +16512,7 @@ export type V2ModelDefaultResponse = V2ModelDefaultResponses[keyof V2ModelDefaul export type V2GenerateTextData = { body: { prompt: string - model?: ModelRef2 | null + model?: ModelRef | null } path?: never query?: { @@ -16814,7 +16532,7 @@ export type V2GenerateTextErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16827,7 +16545,7 @@ export type V2GenerateTextResponses = { /** * GenerateTextResponse */ - 200: GenerateTextResponseV2 + 200: GenerateTextResponse } export type V2GenerateTextResponse = V2GenerateTextResponses[keyof V2GenerateTextResponses] @@ -16852,7 +16570,7 @@ export type V2ProviderListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16866,8 +16584,8 @@ export type V2ProviderListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -16895,11 +16613,11 @@ export type V2ProviderGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ProviderNotFoundError */ - 404: ProviderNotFoundErrorV2 + 404: ProviderNotFoundError /** * ServiceUnavailableError */ @@ -16913,8 +16631,8 @@ export type V2ProviderGetResponses = { * Success */ 200: { - location: LocationInfo2 - data: ProviderV2Info2 + location: LocationInfoV2 + data: ProviderV2Info } } @@ -16940,7 +16658,7 @@ export type V2IntegrationListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationListError = V2IntegrationListErrors[keyof V2IntegrationListErrors] @@ -16950,8 +16668,8 @@ export type V2IntegrationListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -16979,7 +16697,7 @@ export type V2IntegrationGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationGetError = V2IntegrationGetErrors[keyof V2IntegrationGetErrors] @@ -16989,8 +16707,8 @@ export type V2IntegrationGetResponses = { * Success */ 200: { - location: LocationInfo2 - data: IntegrationInfo2 | null + location: LocationInfoV2 + data: IntegrationInfo | null } } @@ -17021,7 +16739,7 @@ export type V2IntegrationConnectKeyErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationConnectKeyError = V2IntegrationConnectKeyErrors[keyof V2IntegrationConnectKeyErrors] @@ -17063,7 +16781,7 @@ export type V2IntegrationConnectOauthErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationConnectOauthError = V2IntegrationConnectOauthErrors[keyof V2IntegrationConnectOauthErrors] @@ -17073,8 +16791,8 @@ export type V2IntegrationConnectOauthResponses = { * Success */ 200: { - location: LocationInfo2 - data: IntegrationAttempt2 + location: LocationInfoV2 + data: IntegrationAttempt } } @@ -17103,7 +16821,7 @@ export type V2IntegrationAttemptCancelErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationAttemptCancelError = V2IntegrationAttemptCancelErrors[keyof V2IntegrationAttemptCancelErrors] @@ -17140,7 +16858,7 @@ export type V2IntegrationAttemptStatusErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationAttemptStatusError = V2IntegrationAttemptStatusErrors[keyof V2IntegrationAttemptStatusErrors] @@ -17150,8 +16868,8 @@ export type V2IntegrationAttemptStatusResponses = { * Success */ 200: { - location: LocationInfo2 - data: IntegrationAttemptStatus2 + location: LocationInfoV2 + data: IntegrationAttemptStatus } } @@ -17182,7 +16900,7 @@ export type V2IntegrationAttemptCompleteErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationAttemptCompleteError = @@ -17218,7 +16936,7 @@ export type V2McpListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2McpListError = V2McpListErrors[keyof V2McpListErrors] @@ -17228,13 +16946,50 @@ export type V2McpListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses] +export type V2McpResourceCatalogData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/mcp/resource" +} + +export type V2McpResourceCatalogErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2McpResourceCatalogError = V2McpResourceCatalogErrors[keyof V2McpResourceCatalogErrors] + +export type V2McpResourceCatalogResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: McpResourceCatalogV2 + } +} + +export type V2McpResourceCatalogResponse = V2McpResourceCatalogResponses[keyof V2McpResourceCatalogResponses] + export type V2CredentialRemoveData = { body?: never path: { @@ -17257,7 +17012,7 @@ export type V2CredentialRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2CredentialRemoveError = V2CredentialRemoveErrors[keyof V2CredentialRemoveErrors] @@ -17295,7 +17050,7 @@ export type V2CredentialUpdateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2CredentialUpdateError = V2CredentialUpdateErrors[keyof V2CredentialUpdateErrors] @@ -17324,7 +17079,7 @@ export type V2ProjectListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectListError = V2ProjectListErrors[keyof V2ProjectListErrors] @@ -17333,7 +17088,7 @@ export type V2ProjectListResponses = { /** * Success */ - 200: Array + 200: Array } export type V2ProjectListResponse = V2ProjectListResponses[keyof V2ProjectListResponses] @@ -17358,7 +17113,7 @@ export type V2ProjectCurrentErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCurrentError = V2ProjectCurrentErrors[keyof V2ProjectCurrentErrors] @@ -17367,7 +17122,7 @@ export type V2ProjectCurrentResponses = { /** * Project.Current */ - 200: ProjectCurrent2 + 200: ProjectCurrent } export type V2ProjectCurrentResponse = V2ProjectCurrentResponses[keyof V2ProjectCurrentResponses] @@ -17394,7 +17149,7 @@ export type V2ProjectDirectoriesErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectDirectoriesError = V2ProjectDirectoriesErrors[keyof V2ProjectDirectoriesErrors] @@ -17403,7 +17158,7 @@ export type V2ProjectDirectoriesResponses = { /** * Project.Directories */ - 200: ProjectDirectories2 + 200: ProjectDirectories } export type V2ProjectDirectoriesResponse = V2ProjectDirectoriesResponses[keyof V2ProjectDirectoriesResponses] @@ -17428,7 +17183,7 @@ export type V2FormRequestListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FormRequestListError = V2FormRequestListErrors[keyof V2FormRequestListErrors] @@ -17438,8 +17193,8 @@ export type V2FormRequestListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -17462,11 +17217,11 @@ export type V2SessionFormListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionFormListError = V2SessionFormListErrors[keyof V2SessionFormListErrors] @@ -17476,14 +17231,14 @@ export type V2SessionFormListResponses = { * Success */ 200: { - data: Array + data: Array } } export type V2SessionFormListResponse = V2SessionFormListResponses[keyof V2SessionFormListResponses] export type V2SessionFormCreateData = { - body: FormCreatePayload2 + body: FormCreatePayloadV2 path: { sessionID: string } @@ -17499,11 +17254,11 @@ export type V2SessionFormCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * ConflictError */ @@ -17517,7 +17272,7 @@ export type V2SessionFormCreateResponses = { * Success */ 200: { - data: FormFormInfo2 | FormUrlInfo2 + data: FormFormInfoV2 | FormUrlInfoV2 } } @@ -17541,11 +17296,11 @@ export type V2SessionFormGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError } export type V2SessionFormGetError = V2SessionFormGetErrors[keyof V2SessionFormGetErrors] @@ -17555,7 +17310,7 @@ export type V2SessionFormGetResponses = { * Success */ 200: { - data: FormFormInfo2 | FormUrlInfo2 + data: FormFormInfoV2 | FormUrlInfoV2 } } @@ -17579,11 +17334,11 @@ export type V2SessionFormStateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError } export type V2SessionFormStateError = V2SessionFormStateErrors[keyof V2SessionFormStateErrors] @@ -17593,14 +17348,14 @@ export type V2SessionFormStateResponses = { * Success */ 200: { - data: FormState2 + data: FormState } } export type V2SessionFormStateResponse = V2SessionFormStateResponses[keyof V2SessionFormStateResponses] export type V2SessionFormReplyData = { - body: FormReply2 + body: FormReply path: { sessionID: string formID: string @@ -17613,19 +17368,19 @@ export type V2SessionFormReplyErrors = { /** * FormInvalidAnswerError | InvalidRequestError */ - 400: FormInvalidAnswerErrorV2 | InvalidRequestErrorV2 + 400: FormInvalidAnswerError | InvalidRequestErrorV2 /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError /** * FormAlreadySettledError */ - 409: FormAlreadySettledErrorV2 + 409: FormAlreadySettledError } export type V2SessionFormReplyError = V2SessionFormReplyErrors[keyof V2SessionFormReplyErrors] @@ -17657,15 +17412,15 @@ export type V2SessionFormCancelErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError /** * FormAlreadySettledError */ - 409: FormAlreadySettledErrorV2 + 409: FormAlreadySettledError } export type V2SessionFormCancelError = V2SessionFormCancelErrors[keyof V2SessionFormCancelErrors] @@ -17699,7 +17454,7 @@ export type V2PermissionRequestListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors] @@ -17709,8 +17464,8 @@ export type V2PermissionRequestListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -17733,7 +17488,7 @@ export type V2PermissionSavedListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors] @@ -17743,7 +17498,7 @@ export type V2PermissionSavedListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17766,7 +17521,7 @@ export type V2PermissionSavedRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors] @@ -17797,11 +17552,11 @@ export type V2SessionPermissionListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors] @@ -17811,7 +17566,7 @@ export type V2SessionPermissionListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17826,7 +17581,7 @@ export type V2SessionPermissionCreateData = { metadata?: { [key: string]: unknown } - source?: PermissionV2Source2 + source?: PermissionV2SourceV2 agent?: string | null } path: { @@ -17844,11 +17599,11 @@ export type V2SessionPermissionCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionPermissionCreateError = V2SessionPermissionCreateErrors[keyof V2SessionPermissionCreateErrors] @@ -17860,7 +17615,7 @@ export type V2SessionPermissionCreateResponses = { 200: { data: { id: string - effect: PermissionV2Effect2 + effect: PermissionV2Effect } } } @@ -17886,11 +17641,11 @@ export type V2SessionPermissionGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | PermissionNotFoundError */ - 404: PermissionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: PermissionNotFoundError | SessionNotFoundError } export type V2SessionPermissionGetError = V2SessionPermissionGetErrors[keyof V2SessionPermissionGetErrors] @@ -17900,7 +17655,7 @@ export type V2SessionPermissionGetResponses = { * Success */ 200: { - data: PermissionV2Request2 + data: PermissionV2RequestV2 } } @@ -17908,7 +17663,7 @@ export type V2SessionPermissionGetResponse = V2SessionPermissionGetResponses[key export type V2SessionPermissionReplyData = { body: { - reply: PermissionV2Reply2 + reply: PermissionV2Reply message?: string | null } path: { @@ -17927,11 +17682,11 @@ export type V2SessionPermissionReplyErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | PermissionNotFoundError */ - 404: PermissionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: PermissionNotFoundError | SessionNotFoundError } export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors] @@ -17966,7 +17721,7 @@ export type V2FsReadErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors] @@ -18001,7 +17756,7 @@ export type V2FsListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FsListError = V2FsListErrors[keyof V2FsListErrors] @@ -18011,8 +17766,8 @@ export type V2FsListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18041,7 +17796,7 @@ export type V2FsFindErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FsFindError = V2FsFindErrors[keyof V2FsFindErrors] @@ -18051,8 +17806,8 @@ export type V2FsFindResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18078,7 +17833,7 @@ export type V2CommandListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2CommandListError = V2CommandListErrors[keyof V2CommandListErrors] @@ -18088,8 +17843,8 @@ export type V2CommandListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18115,7 +17870,7 @@ export type V2SkillListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SkillListError = V2SkillListErrors[keyof V2SkillListErrors] @@ -18125,8 +17880,8 @@ export type V2SkillListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18147,7 +17902,7 @@ export type V2EventSubscribeErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscribeErrors] @@ -18181,7 +17936,7 @@ export type V2PtyListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PtyListError = V2PtyListErrors[keyof V2PtyListErrors] @@ -18191,7 +17946,7 @@ export type V2PtyListResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: Array } } @@ -18226,7 +17981,7 @@ export type V2PtyCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PtyCreateError = V2PtyCreateErrors[keyof V2PtyCreateErrors] @@ -18236,7 +17991,7 @@ export type V2PtyCreateResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: PtyV2 } } @@ -18265,11 +18020,11 @@ export type V2PtyRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyRemoveError = V2PtyRemoveErrors[keyof V2PtyRemoveErrors] @@ -18305,11 +18060,11 @@ export type V2PtyGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyGetError = V2PtyGetErrors[keyof V2PtyGetErrors] @@ -18319,7 +18074,7 @@ export type V2PtyGetResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: PtyV2 } } @@ -18354,11 +18109,11 @@ export type V2PtyUpdateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyUpdateError = V2PtyUpdateErrors[keyof V2PtyUpdateErrors] @@ -18368,7 +18123,7 @@ export type V2PtyUpdateResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: PtyV2 } } @@ -18397,15 +18152,15 @@ export type V2PtyConnectTokenErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ForbiddenError */ - 403: ForbiddenErrorV2 + 403: ForbiddenError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyConnectTokenError = V2PtyConnectTokenErrors[keyof V2PtyConnectTokenErrors] @@ -18415,8 +18170,8 @@ export type V2PtyConnectTokenResponses = { * Success */ 200: { - location: LocationInfo2 - data: PtyTicketConnectToken2 + location: LocationInfoV2 + data: PtyTicketConnectTokenV2 } } @@ -18444,15 +18199,15 @@ export type V2PtyConnectErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ForbiddenError */ - 403: ForbiddenErrorV2 + 403: ForbiddenError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyConnectError = V2PtyConnectErrors[keyof V2PtyConnectErrors] @@ -18486,7 +18241,7 @@ export type V2ShellListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ShellListError = V2ShellListErrors[keyof V2ShellListErrors] @@ -18496,7 +18251,7 @@ export type V2ShellListResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: Array } } @@ -18507,7 +18262,7 @@ export type V2ShellCreateData = { body: { command: string cwd?: string - timeout?: number + timeout: number metadata?: { [key: string]: unknown } @@ -18530,7 +18285,7 @@ export type V2ShellCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ShellCreateError = V2ShellCreateErrors[keyof V2ShellCreateErrors] @@ -18540,7 +18295,7 @@ export type V2ShellCreateResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: ShellV2 } } @@ -18569,11 +18324,11 @@ export type V2ShellRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ShellNotFoundError */ - 404: ShellNotFoundErrorV2 + 404: ShellNotFoundError } export type V2ShellRemoveError = V2ShellRemoveErrors[keyof V2ShellRemoveErrors] @@ -18609,11 +18364,11 @@ export type V2ShellGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ShellNotFoundError */ - 404: ShellNotFoundErrorV2 + 404: ShellNotFoundError } export type V2ShellGetError = V2ShellGetErrors[keyof V2ShellGetErrors] @@ -18623,13 +18378,58 @@ export type V2ShellGetResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: ShellV2 } } export type V2ShellGetResponse = V2ShellGetResponses[keyof V2ShellGetResponses] +export type V2ShellTimeoutData = { + body: { + timeout: number + } + path: { + id: string + } + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/shell/{id}/timeout" +} + +export type V2ShellTimeoutErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ShellNotFoundError + */ + 404: ShellNotFoundError +} + +export type V2ShellTimeoutError = V2ShellTimeoutErrors[keyof V2ShellTimeoutErrors] + +export type V2ShellTimeoutResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: ShellV2 + } +} + +export type V2ShellTimeoutResponse = V2ShellTimeoutResponses[keyof V2ShellTimeoutResponses] + export type V2ShellOutputData = { body?: never path: { @@ -18654,11 +18454,11 @@ export type V2ShellOutputErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ShellNotFoundError */ - 404: ShellNotFoundErrorV2 + 404: ShellNotFoundError } export type V2ShellOutputError = V2ShellOutputErrors[keyof V2ShellOutputErrors] @@ -18668,7 +18468,7 @@ export type V2ShellOutputResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: { output: string cursor: number @@ -18700,7 +18500,7 @@ export type V2QuestionRequestListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2QuestionRequestListError = V2QuestionRequestListErrors[keyof V2QuestionRequestListErrors] @@ -18710,8 +18510,8 @@ export type V2QuestionRequestListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18734,11 +18534,11 @@ export type V2SessionQuestionListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors] @@ -18748,14 +18548,14 @@ export type V2SessionQuestionListResponses = { * Success */ 200: { - data: Array + data: Array } } export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses] export type V2SessionQuestionReplyData = { - body: QuestionV2Reply2 + body: QuestionV2Reply path: { sessionID: string requestID: string @@ -18772,11 +18572,11 @@ export type V2SessionQuestionReplyErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | QuestionNotFoundError */ - 404: QuestionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: QuestionNotFoundError | SessionNotFoundError } export type V2SessionQuestionReplyError = V2SessionQuestionReplyErrors[keyof V2SessionQuestionReplyErrors] @@ -18808,11 +18608,11 @@ export type V2SessionQuestionRejectErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | QuestionNotFoundError */ - 404: QuestionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: QuestionNotFoundError | SessionNotFoundError } export type V2SessionQuestionRejectError = V2SessionQuestionRejectErrors[keyof V2SessionQuestionRejectErrors] @@ -18846,7 +18646,7 @@ export type V2ReferenceListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ReferenceListError = V2ReferenceListErrors[keyof V2ReferenceListErrors] @@ -18856,8 +18656,8 @@ export type V2ReferenceListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18888,7 +18688,7 @@ export type V2ProjectCopyRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCopyRemoveError = V2ProjectCopyRemoveErrors[keyof V2ProjectCopyRemoveErrors] @@ -18928,7 +18728,7 @@ export type V2ProjectCopyCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCopyCreateError = V2ProjectCopyCreateErrors[keyof V2ProjectCopyCreateErrors] @@ -18937,7 +18737,7 @@ export type V2ProjectCopyCreateResponses = { /** * ProjectCopy.Copy */ - 200: ProjectCopyCopy2 + 200: ProjectCopyCopy } export type V2ProjectCopyCreateResponse = V2ProjectCopyCreateResponses[keyof V2ProjectCopyCreateResponses] @@ -18964,7 +18764,7 @@ export type V2ProjectCopyRefreshErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCopyRefreshError = V2ProjectCopyRefreshErrors[keyof V2ProjectCopyRefreshErrors] @@ -18998,7 +18798,7 @@ export type V2VcsStatusErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2VcsStatusError = V2VcsStatusErrors[keyof V2VcsStatusErrors] @@ -19008,7 +18808,7 @@ export type V2VcsStatusResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: Array } } @@ -19023,7 +18823,7 @@ export type V2VcsDiffData = { directory?: string | null workspace?: string | null } | null - mode: VcsMode2 + mode: VcsMode context?: string | null } url: "/api/vcs/diff" @@ -19037,7 +18837,7 @@ export type V2VcsDiffErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2VcsDiffError = V2VcsDiffErrors[keyof V2VcsDiffErrors] @@ -19047,21 +18847,26 @@ export type V2VcsDiffResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] -export type V2DebugLocationData = { +export type V2DebugLocationEvictData = { body?: never path?: never - query?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } url: "/api/debug/location" } -export type V2DebugLocationErrors = { +export type V2DebugLocationEvictErrors = { /** * InvalidRequestError */ @@ -19069,19 +18874,48 @@ export type V2DebugLocationErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } -export type V2DebugLocationError = V2DebugLocationErrors[keyof V2DebugLocationErrors] +export type V2DebugLocationEvictError = V2DebugLocationEvictErrors[keyof V2DebugLocationEvictErrors] -export type V2DebugLocationResponses = { +export type V2DebugLocationEvictResponses = { + /** + * + */ + 204: void +} + +export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses] + +export type V2DebugLocationListData = { + body?: never + path?: never + query?: never + url: "/api/debug/location" +} + +export type V2DebugLocationListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2DebugLocationListError = V2DebugLocationListErrors[keyof V2DebugLocationListErrors] + +export type V2DebugLocationListResponses = { /** * Success */ - 200: Array + 200: Array } -export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] +export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses] export type PtyConnectData = { body?: never diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts index f5ffd536e4..ec797c0032 100644 --- a/packages/server/src/handlers/debug.ts +++ b/packages/server/src/handlers/debug.ts @@ -2,13 +2,24 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Effect, Option, RcMap } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { requestRef } from "../location" export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) => - handlers.handle( - "debug.location", - Effect.fn(function* () { - const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) - return Array.from(yield* RcMap.keys(locations.rcMap)) - }), - ), + handlers + .handle( + "debug.location", + Effect.fn(function* () { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + return Array.from(yield* RcMap.keys(locations.rcMap)) + }), + ) + .handle( + "debug.location.evict", + Effect.fn(function* (ctx) { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + // Resolve through requestRef so the key matches the shape the location + // middleware cached the services under. + yield* locations.invalidate(requestRef(ctx.request)) + }), + ), ) diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 4d702f24c9..8f423534d4 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -6,20 +6,28 @@ import { response } from "../location" export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => Effect.gen(function* () { - return handlers.handle( - "mcp.list", - Effect.fn(function* () { - const service = yield* MCP.Service - return yield* response( - service - .servers() - .pipe( - Effect.map((servers) => - servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + return handlers + .handle( + "mcp.list", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response( + service + .servers() + .pipe( + Effect.map((servers) => + servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + ), ), - ), - ) - }), - ) + ) + }), + ) + .handle( + "mcp.resource.catalog", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response(service.resourceCatalog()) + }), + ) }), ) diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index 93734c628d..40e2ba1077 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -16,7 +16,7 @@ const Cursor = Schema.Struct({ const decodeCursor = Schema.decodeUnknownSync(Cursor) const cursor = { - encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + encode(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") { return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url") }, decode(input: string) { diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index b5d6ef37cf..a6ebdb84d7 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -20,7 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.default", Effect.fn(function* () { const plugins = yield* PluginSupervisor.Service - yield* plugins.ready.pipe( + yield* plugins.flush.pipe( Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index aa79b8bd51..e3160cc231 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,8 +9,8 @@ import { ConflictError, CommandEvaluationError, CommandNotFoundError, - InvalidCursorError, InvalidRequestError, + InvalidCursorError, MessageNotFoundError, ServiceUnavailableError, SessionBusyError, @@ -24,6 +25,7 @@ const DefaultSessionsLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service + const moveSession = yield* MoveSession.Service return handlers .handle( @@ -111,6 +113,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.remove", + Effect.fn(function* (ctx) { + yield* session.remove(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.fork", Effect.fn(function* (ctx) { @@ -185,6 +203,43 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.move", + Effect.fn(function* (ctx) { + yield* moveSession.moveSession({ + sessionID: ctx.params.sessionID, + destination: ctx.payload.destination, + moveChanges: ctx.payload.moveChanges, + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => + Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), + ), + Effect.catchTag("MoveSession.ApplyChangesError", () => + Effect.fail( + new InvalidRequestError({ + message: + "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", + }), + ), + ), + Effect.catchTag("MoveSession.CaptureChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { @@ -313,6 +368,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl text: ctx.payload.text, description: ctx.payload.description, metadata: ctx.payload.metadata, + resume: ctx.payload.resume, }) .pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -348,44 +404,26 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.compact", Effect.fn(function* (ctx) { - yield* session.compact({ sessionID: ctx.params.sessionID }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - Effect.catchTag("Session.OperationUnavailableError", (error) => - Effect.fail( - new ServiceUnavailableError({ - message: `Session ${error.operation} is not available yet`, - service: `session.${error.operation}`, - }), - ), - ), - Effect.catchTag( - "Session.BusyError", - (error) => - new SessionBusyError({ - sessionID: error.sessionID, - message: `Session is busy: ${error.sessionID}`, - }), - ), - Effect.catchTag("Session.MessageDecodeError", (error) => { - const ref = `err_${crypto.randomUUID().slice(0, 8)}` - return Effect.logError("failed to decode session message during compaction").pipe( - Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), - Effect.andThen( - Effect.fail( - new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), - ), + return { + data: yield* session.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), ), - ) - }), - ) - return HttpApiSchema.NoContent.make() + ), + Effect.catchTag("Session.CompactionConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`, + resource: error.inputID, + }), + ), + ), + ), + } }), ) .handle( diff --git a/packages/server/src/handlers/shell.ts b/packages/server/src/handlers/shell.ts index 6f4c57d538..ac1ae09a8c 100644 --- a/packages/server/src/handlers/shell.ts +++ b/packages/server/src/handlers/shell.ts @@ -38,6 +38,20 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers) ) }), ) + .handle( + "shell.timeout", + Effect.fn(function* (ctx) { + const shell = yield* Shell.Service + return yield* response( + shell.timeout(ctx.params.id, ctx.payload.timeout).pipe( + Effect.catchTag( + "Shell.NotFoundError", + () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), + ), + ), + ) + }), + ) .handle( "shell.output", Effect.fn(function* (ctx) { diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index de6f4b328f..971d77734b 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,14 +1,9 @@ export * as ServerProcess from "./process" import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node" -import { Credential } from "@opencode-ai/core/credential" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { Project } from "@opencode-ai/core/project" import { HealthGroup } from "@opencode-ai/protocol/groups/health" import { Context, Effect, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { createServer } from "node:http" import { ServerAuth } from "./auth" @@ -53,9 +48,11 @@ function listen(options: Options) { function bind(hostname: string, port: number, password: string) { const server = createServer() return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + createRoutes(password).pipe( + Layer.flatMap((context) => + HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger), + ), Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe( Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 606fde0b84..773b01ad2f 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -8,6 +8,7 @@ import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -19,7 +20,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Effect, Layer, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" @@ -37,9 +38,11 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, + MoveSession.node, Project.node, SessionV2.node, PluginRuntime.providerNode, + SdkPlugins.node, PermissionSaved.node, PtyTicket.node, Credential.node, @@ -55,52 +58,61 @@ export function createRoutes(password?: string) { ) } -export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { - return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins) +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) } -function makeRoutes( - auth: Layer.Layer, - sdkPlugins?: SdkPlugins.Store, -) { +function makeRoutes(auth: Layer.Layer) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], - ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ] - // Simulation replacements are loaded via dynamic import so the simulation - // module is never eagerly loaded. Layer.unwrap defers both the import and - // the app-node build to layer-build time; when simulation is off the branch - // is byte-for-byte identical to a plain AppNodeBuilder.build call. - const serviceLayer = simulationEnabled() + const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) - return AppNodeBuilder.build(applicationServices, [...replacements, ...simulationReplacements]) + const { simulationReplacements, startDriveServer } = yield* Effect.promise( + () => import("@opencode-ai/simulation/backend"), + ) + if (driveEnabled()) startDriveServer() + return AppNodeBuilder.build(applicationServices, [ + ...replacements, + ...(simulateEnabled() ? simulationReplacements : []), + ]) }), ) : AppNodeBuilder.build(applicationServices, replacements) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers.pipe(Layer.provide(serviceLayer))), - Layer.provide(formLocationLayer), - Layer.provide(sessionLocationLayer), - Layer.provide(layer), - Layer.provide(authorizationLayer), - Layer.provide(schemaErrorLayer), - Layer.provide(auth), - Layer.provide(serviceLayer), - Layer.provide(Observability.layer), + return serviceLayer.pipe( + Layer.flatMap((context) => { + const services = Layer.succeedContext(context) + const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(handlers.pipe(Layer.provide(services))), + Layer.provide(formLocationLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(layer), + Layer.provide(authorizationLayer), + Layer.provide(schemaErrorLayer), + Layer.provide(auth), + Layer.provide(Observability.layer), + HttpRouter.provideRequest(requestServices), + Layer.provideMerge(services), + Layer.provideMerge(HttpRouter.layer), + ) + }), ) } -function simulationEnabled() { - return !!process.env.OPENCODE_SIMULATION +function simulateEnabled() { + return !!process.env.OPENCODE_SIMULATE +} + +function driveEnabled() { + return !!process.env.OPENCODE_DRIVE } export const routes = createRoutes() -export const webHandler = () => - HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 0e5dec34b2..f147cd7388 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -536,6 +536,7 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "patch": case "apply_patch": return { icon: "code-lines", @@ -728,7 +729,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1449,7 +1450,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name]?.render } export const ToolRegistry = { @@ -2272,7 +2273,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 48e8eee310..6fccfc305d 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" type LegacyDiff = { file: string @@ -12,8 +12,7 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 8db21f025b..ec94af2f2d 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import { type FileContent, type FileDiffInfo, type VcsFileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -62,14 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = (FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } - function diff(value: unknown): value is ReviewDiff { if (!value || typeof value !== "object" || Array.isArray(value)) return false if (!("file" in value) || typeof value.file !== "string") return false @@ -185,7 +183,7 @@ export const SessionReview = (props: SessionReviewProps) => { const itemsMap = createMemo(() => Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])), ) - const files = createMemo(() => props.diffs.map((diff) => diff.file!)) + const files = createMemo(() => props.diffs.map((diff) => diff.file)) const grouped = createMemo(() => { const next = new Map() for (const comment of props.comments ?? []) { diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 4ebf6459b1..cda95c0e8e 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -1,8 +1,9 @@ import { AssistantMessage, - type SnapshotFileDiff, + type FileDiffInfo, Message as MessageType, Part as PartType, + type UserMessage, } from "@opencode-ai/sdk/v2/client" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" @@ -90,10 +91,17 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiffInput = NonNullable["diffs"]>[number] +type SummaryDiff = FileDiffInfo -function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { - return typeof value.file === "string" +function summaryDiff(value: SummaryDiffInput): value is SummaryDiff { + return ( + typeof value.file === "string" && + typeof value.patch === "string" && + typeof value.additions === "number" && + typeof value.deletions === "number" && + value.status !== undefined + ) } const hidden = new Set(["todowrite"]) diff --git a/packages/session-ui/src/components/tool-error-card.stories.tsx b/packages/session-ui/src/components/tool-error-card.stories.tsx index dd60075812..90348b2a95 100644 --- a/packages/session-ui/src/components/tool-error-card.stories.tsx +++ b/packages/session-ui/src/components/tool-error-card.stories.tsx @@ -5,7 +5,7 @@ const docs = `### Overview Tool call failure summary styled like a tool trigger. ### API -- Required: \`tool\` (tool id, e.g. apply_patch, bash) +- Required: \`tool\` (tool id, e.g. patch, bash) - Required: \`error\` (error string) ### Behavior @@ -14,9 +14,9 @@ Tool call failure summary styled like a tool trigger. const samples = [ { - tool: "apply_patch", + tool: "patch", error: - "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", + "patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", }, { tool: "bash", @@ -62,13 +62,13 @@ export default { }, }, args: { - tool: "apply_patch", + tool: "patch", error: samples[0].error, }, argTypes: { tool: { control: "select", - options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], + options: ["patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], }, error: { control: "text", diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a2753..aaedded025 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -51,6 +51,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", + patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..d505249931 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, Message, Part, Provider, Session, SessionStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -21,7 +21,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 3e7bd83051..c05fe3a745 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -6,7 +6,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" -import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileContent, FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -27,7 +27,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 29613ff28a..624cf32ef7 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -10,12 +10,15 @@ "./backend/*": "./src/backend/*.ts", "./frontend": "./src/frontend/simulation.ts", "./frontend/*": "./src/frontend/*.ts", - "./protocol": "./src/protocol/index.ts" + "./protocol": "./src/protocol/index.ts", + "./recording": "./src/recording.ts" }, "scripts": { "typecheck": "tsgo --noEmit" }, "dependencies": { + "@fontsource/adwaita-mono": "5.2.1", + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index 28bb18c009..eaacc162c1 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" import { SimulationProtocol } from "../protocol" import { SimulationLLMExchange } from "./llm-exchange" -import { SimulationNetwork } from "./network" /** * Backend-hosted simulation control WebSocket. @@ -19,19 +18,15 @@ import { SimulationNetwork } from "./network" * - `llm.finish` { id, reason? } finish an exchange * - `llm.disconnect` { id } abruptly terminate an exchange without a finish * - `llm.pending` list open exchanges - * - `network.log` simulated network request log */ -const DefaultPort = 40950 -const MaxPortAttempts = 100 - type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> function parseRequest(input: string | Buffer) { - return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) + return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { +async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise { switch (request.method) { case "llm.attach": { socket.data.unsubscribe?.() @@ -41,73 +36,58 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc return { attached: true } } case "llm.chunk": { - const params = await SimulationProtocol.Backend.decodeChunkParams(request.params) await Effect.runPromise( SimulationLLMExchange.push( - params.id, - params.items.map((item) => ({ type: "item", item }) as const), + request.params.id, + request.params.items.map((item) => ({ type: "item", item }) as const), ), ) return { ok: true } } case "llm.finish": { - const params = await SimulationProtocol.Backend.decodeFinishParams(request.params) - await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) + await Effect.runPromise( + SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]), + ) return { ok: true } } case "llm.disconnect": { - const params = await SimulationProtocol.Backend.decodeDisconnectParams(request.params) - await Effect.runPromise(SimulationLLMExchange.disconnect(params.id)) + await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id)) return { ok: true } } case "llm.pending": return { exchanges: SimulationLLMExchange.pending() } - case "network.log": - return { entries: SimulationNetwork.log() } - } - throw new Error(`Unknown simulation control method: ${request.method}`) -} - -function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ unsubscribe?: () => void }> { - try { - return Bun.serve<{ unsubscribe?: () => void }>({ - hostname: "127.0.0.1", - port, - fetch(request, server) { - if (server.upgrade(request, { data: {} })) return undefined - return new Response("opencode simulation control websocket", { status: 426 }) - }, - websocket: { - close(socket) { - socket.data.unsubscribe?.() - }, - async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined - try { - request = parseRequest(message) - const result = await handle(socket, request) - const response = SimulationProtocol.JsonRpc.success(request.id, result) - if (response) socket.send(JSON.stringify(response)) - } catch (error) { - socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) - } - }, - }, - }) - } catch (error) { - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() - const unavailable = message.includes("eaddrinuse") || message.includes("in use") - if (!unavailable || attempts <= 1 || port >= 65535) throw error - return serve(port + 1, attempts - 1) } } -export function start() { - const server = serve() - const url = `ws://${server.hostname}:${server.port}` - process.stderr.write(`opencode simulation backend control websocket: ${url}\n`) +export function start(endpoint: string) { + const url = new URL(endpoint) + const server = Bun.serve<{ unsubscribe?: () => void }>({ + hostname: url.hostname, + port: Number(url.port), + fetch(request, server) { + if (server.upgrade(request, { data: {} })) return undefined + return new Response("opencode drive backend websocket", { status: 426 }) + }, + websocket: { + close(socket) { + socket.data.unsubscribe?.() + }, + async message(socket, message) { + let request: SimulationProtocol.Backend.Request | undefined + try { + request = parseRequest(message) + const result = await handle(socket, request) + const response = SimulationProtocol.JsonRpc.success(request.id, result) + if (response) socket.send(JSON.stringify(response)) + } catch (error) { + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) + } + }, + }, + }) + process.stderr.write(`opencode drive backend websocket: ${endpoint}\n`) return { - url, + url: endpoint, stop: () => { server.stop(true) }, diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts deleted file mode 100644 index 01a7b00329..0000000000 --- a/packages/simulation/src/backend/filesystem.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { Effect, FileSystem, Layer, Option, Stream } from "effect" -import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError" -import nodeFs from "fs" -import path from "path" - -/** - * In-memory simulated `FileSystem.FileSystem`. - * - * Replaces the `NodeFileSystem` platform node when the server runs in - * simulation mode. Backed by a flat map of absolute paths to entries and - * rooted at a single directory (the simulation anchor): paths that resolve - * outside the root fail with `PermissionDenied` so host filesystem escapes - * are loud. Only the operations the app actually uses are implemented; - * everything else dies with a clear defect. - * - * Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten - * for the V2 platform node shape without the `just-bash` dependency. - */ - -export interface Options { - readonly root: string - readonly files?: Record -} - -interface FileEntry { - readonly type: "File" - content: Uint8Array - mode: number - mtime: Date -} - -interface DirectoryEntry { - readonly type: "Directory" - mode: number - mtime: Date -} - -type Entry = FileEntry | DirectoryEntry - -export function make(options: Options): FileSystem.FileSystem { - const root = path.resolve(options.root) - const store = new Map() - const temp = { value: 0 } - const encoder = new TextEncoder() - store.set(root, makeDirectoryEntry()) - - const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root)) - - const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved))) - - const fail = ( - tag: SystemErrorTag, - method: string, - file: string, - description?: string, - ): Effect.Effect => - Effect.fail( - systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }), - ) - - const locate = (method: string, file: string): Effect.Effect => { - const resolved = path.resolve(root, file) - if (within(resolved)) return Effect.succeed(resolved) - return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root") - } - - const requireEntry = (method: string, file: string): Effect.Effect => - locate(method, file).pipe( - Effect.flatMap((resolved) => { - const entry = store.get(resolved) - if (!entry) return fail("NotFound", method, file) - return Effect.succeed([resolved, entry] as const) - }), - ) - - const requireParentDirectory = ( - method: string, - resolved: string, - file: string, - ): Effect.Effect => { - const parent = store.get(path.dirname(resolved)) - if (parent?.type === "Directory") return Effect.void - return fail("NotFound", method, file, "parent directory does not exist") - } - - // Creates every missing directory between root and resolved (inclusive). - const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect => - Effect.suspend(() => { - const segments = path.relative(root, resolved).split(path.sep).filter(Boolean) - const conflict = segments.reduce>((current, segment) => { - if (typeof current !== "string") return current - const next = path.join(current, segment) - const entry = store.get(next) - if (entry && entry.type !== "Directory") - return fail("AlreadyExists", method, file, "path component is not a directory") - if (!entry) store.set(next, makeDirectoryEntry()) - return next - }, root) - return typeof conflict === "string" ? Effect.void : conflict - }) - - // Seed initial files, creating parents as needed. Entries outside the root are ignored. - for (const [file, content] of Object.entries(options.files ?? {})) { - const resolved = path.resolve(root, file) - if (!within(resolved)) continue - Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved))) - store.set(resolved, { - type: "File", - content: typeof content === "string" ? encoder.encode(content) : content.slice(), - mode: 0o644, - mtime: new Date(), - }) - } - - // Probe operations report NotFound outside the root instead of - // PermissionDenied: walk-up loops (project discovery, findUp, globUp) - // legitimately probe ancestor directories of the anchor and must observe - // "nothing there". Content access and mutation outside the root stay loud. - const probe = (method: string, file: string): Effect.Effect => - Effect.suspend(() => { - const resolved = path.resolve(root, file) - const entry = within(resolved) ? store.get(resolved) : undefined - if (!entry) return fail("NotFound", method, file) - return Effect.succeed(entry) - }) - - const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo)) - - const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid) - - const chmod: FileSystem.FileSystem["chmod"] = (file, mode) => - requireEntry("chmod", file).pipe( - Effect.map(([, entry]) => { - entry.mode = mode - }), - ) - - const realPath: FileSystem.FileSystem["realPath"] = (file) => - requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved)) - - const readFile: FileSystem.FileSystem["readFile"] = (file) => - requireEntry("readFile", file).pipe( - Effect.flatMap(([, entry]) => { - if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory") - return Effect.succeed(entry.content.slice()) - }), - ) - - const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) => - locate("writeFile", file).pipe( - Effect.flatMap((resolved) => { - const existing = store.get(resolved) - if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory") - return requireParentDirectory("writeFile", resolved, file).pipe( - Effect.map(() => { - store.set(resolved, { - type: "File", - content: data.slice(), - mode: writeOptions?.mode ?? existing?.mode ?? 0o644, - mtime: new Date(), - }) - }), - ) - }), - ) - - const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) => - locate("makeDirectory", file).pipe( - Effect.flatMap((resolved) => { - if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved) - if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file) - return requireParentDirectory("makeDirectory", resolved, file).pipe( - Effect.map(() => { - store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() }) - }), - ) - }), - ) - - const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) => - requireEntry("readDirectory", file).pipe( - Effect.flatMap(([resolved, entry]) => { - if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory") - const children = childrenOf(resolved) - const names = readOptions?.recursive - ? children.map((key) => path.relative(resolved, key)) - : children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key)) - return Effect.succeed(names.sort((a, b) => a.localeCompare(b))) - }), - ) - - const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) => - locate("remove", file).pipe( - Effect.flatMap((resolved) => { - const entry = store.get(resolved) - if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file) - const children = childrenOf(resolved) - if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive) - return fail("Unknown", "remove", file, "directory is not empty") - for (const key of children) store.delete(key) - store.delete(resolved) - // The root itself must always exist. - if (resolved === root) store.set(root, makeDirectoryEntry()) - return Effect.void - }), - ) - - const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => - Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe( - Effect.flatMap(([from, to]) => { - const entry = store.get(from) - if (!entry) return fail("NotFound", "rename", oldPath) - return requireParentDirectory("rename", to, newPath).pipe( - Effect.map(() => { - const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const) - for (const [key] of moved) store.delete(key) - for (const key of [to, ...childrenOf(to)]) store.delete(key) - for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value) - }), - ) - }), - ) - - const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) => - Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe( - Effect.flatMap(([from, to]) => { - const entry = store.get(from) - if (!entry) return fail("NotFound", "copy", fromPath) - return requireParentDirectory("copy", to, toPath).pipe( - Effect.map(() => { - for (const key of [from, ...childrenOf(from)]) { - const source = store.get(key)! - const target = key === from ? to : to + key.slice(from.length) - store.set( - target, - source.type === "File" - ? { ...source, content: source.content.slice(), mtime: new Date() } - : { ...source, mtime: new Date() }, - ) - } - }), - ) - }), - ) - - const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => - readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content))) - - const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) => - Effect.suspend(() => { - const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp") - const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`) - return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file)) - }) - - const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) => - Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) => - remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), - ) - - // Read-only file handle: enough for the read tool's stat/seek/readAlloc use. - const open: FileSystem.FileSystem["open"] = (file) => - requireEntry("open", file).pipe( - Effect.map(([resolved]) => { - const position = { value: 0 } - const contentOf = () => { - const current = store.get(resolved) - return current?.type === "File" ? current.content : new Uint8Array() - } - return { - [FileSystem.FileTypeId]: FileSystem.FileTypeId, - fd: FileSystem.FileDescriptor(0), - stat: Effect.suspend(() => stat(resolved)), - seek: (offset, from) => - Effect.sync(() => { - position.value = from === "start" ? Number(offset) : position.value + Number(offset) - }), - sync: Effect.void, - read: (buffer) => - Effect.sync(() => { - const chunk = contentOf().subarray(position.value, position.value + buffer.length) - buffer.set(chunk) - position.value += chunk.length - return FileSystem.Size(chunk.length) - }), - readAlloc: (size) => - Effect.sync(() => { - const chunk = contentOf().slice(position.value, position.value + Number(size)) - position.value += chunk.length - return chunk.length === 0 ? Option.none() : Option.some(chunk) - }), - truncate: () => unimplemented("File.truncate"), - write: () => unimplemented("File.write"), - writeAll: () => unimplemented("File.writeAll"), - } satisfies FileSystem.File - }), - ) - - return FileSystem.make({ - access, - chmod, - chown: () => unimplemented("chown"), - copy, - copyFile, - link: () => unimplemented("link"), - makeDirectory, - makeTempDirectory, - makeTempDirectoryScoped, - makeTempFile: () => unimplemented("makeTempFile"), - makeTempFileScoped: () => unimplemented("makeTempFileScoped"), - open, - readDirectory, - readFile, - readLink: () => unimplemented("readLink"), - realPath, - remove, - rename, - stat, - symlink: () => unimplemented("symlink"), - truncate: () => unimplemented("truncate"), - utimes: () => unimplemented("utimes"), - watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")), - writeFile, - }) -} - -/** - * Lazily constructed layer so the root defaults to `process.cwd()` at - * layer-build time (the simulation anchor directory), not at import time. - * - * When `OPENCODE_SIMULATION_STATE` points at a snapshot directory, its - * `project/` contents are read from the host once at build time and seeded - * into the in-memory tree, joined onto the anchor root. - */ -export const layer = (options?: Partial) => - Layer.sync(FileSystem.FileSystem)(() => - make({ - root: options?.root ?? process.cwd(), - files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files }, - }), - ) - -function loadSnapshotFiles(stateDirectory: string | undefined) { - if (!stateDirectory) return {} - const project = path.join(stateDirectory, "project") - if (!nodeFs.existsSync(project)) return {} - const files: Record = {} - const walk = (dir: string) => { - for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) { - const file = path.join(dir, entry.name) - if (entry.isDirectory()) walk(file) - if (entry.isFile()) files[path.relative(project, file)] = new Uint8Array(nodeFs.readFileSync(file)) - } - } - walk(project) - return files -} - -function makeDirectoryEntry(): Entry { - return { type: "Directory", mode: 0o755, mtime: new Date() } -} - -function withSep(dir: string) { - return dir.endsWith(path.sep) ? dir : dir + path.sep -} - -function toInfo(entry: Entry): FileSystem.File.Info { - return { - type: entry.type, - mtime: Option.some(entry.mtime), - atime: Option.some(entry.mtime), - birthtime: Option.some(entry.mtime), - dev: 0, - ino: Option.none(), - mode: entry.mode, - nlink: Option.none(), - uid: Option.none(), - gid: Option.none(), - rdev: Option.none(), - size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0), - blksize: Option.none(), - blocks: Option.none(), - } -} - -function unimplemented(method: string) { - return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`)) -} - -export * as SimulationFileSystem from "./filesystem" diff --git a/packages/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts deleted file mode 100644 index 4ee267fd64..0000000000 --- a/packages/simulation/src/backend/fs-util.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Effect, FileSystem, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Glob } from "@opencode-ai/core/util/glob" -import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" -import { filesystem } from "@opencode-ai/core/effect/app-node-platform" -import path from "path" - -/** - * Simulation replacement for `FSUtil`. - * - * This implementation is intentionally self-contained and only uses the - * injected simulated `FileSystem.FileSystem`. The default FSUtil layer has a - * few helpers that reach host-node APIs directly; depending on it here makes it - * easy for mutation paths to escape or miss the in-memory project tree. - */ - -const layer = Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - - const existsSafe = Effect.fn("SimulationFSUtil.existsSafe")(function* (file: string) { - return yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)) - }) - - const isDir = Effect.fn("SimulationFSUtil.isDir")(function* (file: string) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - return info?.type === "Directory" - }) - - const isFile = Effect.fn("SimulationFSUtil.isFile")(function* (file: string) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - return info?.type === "File" - }) - - const realPath = Effect.fn("SimulationFSUtil.realPath")(function* (file: string) { - return yield* fs.realPath(file) - }) - - const stat = Effect.fn("SimulationFSUtil.stat")(function* (file: string) { - return yield* fs.stat(file) - }) - - const readFile = Effect.fn("SimulationFSUtil.readFile")(function* (file: string) { - return yield* fs.readFile(file) - }) - - const readFileString = Effect.fn("SimulationFSUtil.readFileString")(function* (file: string) { - return yield* fs.readFileString(file) - }) - - const readFileStringSafe = Effect.fn("SimulationFSUtil.readFileStringSafe")(function* (file: string) { - return yield* fs - .readFileString(file) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) - }) - - const readJson = Effect.fn("SimulationFSUtil.readJson")(function* (file: string) { - const text = yield* readFileString(file) - return JSON.parse(text) as unknown - }) - - const writeFile = Effect.fn("SimulationFSUtil.writeFile")(function* ( - file: string, - data: Uint8Array, - options?: Parameters[2], - ) { - return yield* fs.writeFile(file, data, options) - }) - - const writeFileString = Effect.fn("SimulationFSUtil.writeFileString")(function* ( - file: string, - data: string, - options?: Parameters[2], - ) { - return yield* fs.writeFileString(file, data, options) - }) - - const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, options) => fs.makeDirectory(file, options) - - const ensureDir = Effect.fn("SimulationFSUtil.ensureDir")(function* (file: string) { - yield* fs.makeDirectory(file, { recursive: true }) - }) - - const writeWithDirs = Effect.fn("SimulationFSUtil.writeWithDirs")(function* ( - file: string, - content: string | Uint8Array, - mode?: number, - ) { - const write = - typeof content === "string" - ? fs.writeFileString(file, content) - : fs.writeFile(file, content) - yield* write.pipe( - Effect.catchReason("PlatformError", "NotFound", () => - fs.makeDirectory(path.dirname(file), { recursive: true }).pipe(Effect.andThen(write)), - ), - ) - if (mode !== undefined) yield* fs.chmod(file, mode) - }) - - const writeJson = Effect.fn("SimulationFSUtil.writeJson")(function* (file: string, data: unknown, mode?: number) { - yield* writeFileString(file, JSON.stringify(data, null, 2)) - if (mode !== undefined) yield* fs.chmod(file, mode) - }) - - const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) { - const names = yield* fs.readDirectory(dirPath) - return yield* Effect.forEach(names, (name) => - fs.stat(path.join(dirPath, name)).pipe( - Effect.map( - (info): FSUtil.DirEntry => ({ - name, - type: - info.type === "Directory" - ? "directory" - : info.type === "File" - ? "file" - : info.type === "SymbolicLink" - ? "symlink" - : "other", - }), - ), - Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })), - ), - ) - }) - - const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) { - return path.resolve(input) - }) - - const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) { - const cwd = path.resolve(options?.cwd ?? process.cwd()) - const entries = yield* fs - .readDirectory(cwd, { recursive: true }) - .pipe(Effect.orElseSucceed(() => [] as string[])) - const matches = yield* Effect.forEach(entries, (entry) => - fs.stat(path.join(cwd, entry)).pipe( - Effect.map((info) => ({ entry, type: info.type })), - Effect.orElseSucceed(() => undefined), - ), - ) - return matches - .filter((item) => item !== undefined) - .filter((item) => options?.include === "all" || item.type === "File") - .filter((item) => Glob.match(pattern, item.entry)) - .map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry)) - .sort((a, b) => a.localeCompare(b)) - }) - - const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) { - const result: string[] = [] - let current = path.resolve(start) - while (true) { - result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }))) - if (stop === current) break - const parent = path.dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const up = Effect.fn("SimulationFSUtil.up")(function* (options: { targets: string[]; start: string; stop?: string }) { - const result: string[] = [] - let current = path.resolve(options.start) - while (true) { - for (const target of options.targets) { - const search = path.join(current, target) - if (yield* fs.exists(search)) result.push(search) - } - if (options.stop === current) break - const parent = path.dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const findUp = Effect.fn("SimulationFSUtil.findUp")(function* (target: string, start: string, stop?: string) { - return yield* up({ targets: [target], start, stop }) - }) - - return FSUtil.Service.of({ - ...fs, - realPath, - stat, - readFile, - readFileString, - writeFile, - writeFileString, - makeDirectory, - isDir, - isFile, - existsSafe, - readFileStringSafe, - readJson, - writeJson, - ensureDir, - writeWithDirs, - readDirectoryEntries, - resolve, - findUp, - up, - globUp, - glob, - globMatch: Glob.match, - }) - }), -) - -export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) - -export * as SimulationFSUtil from "./fs-util" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index dd735d40dd..2b8bb876e6 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -1,28 +1,20 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { DriveManifest } from "../manifest" import { SimulationControl } from "./control" -import { SimulationFileSystem } from "./filesystem" -import { SimulationFSUtil } from "./fs-util" import { SimulationNetwork } from "./network" import { SimulationOpenAI } from "./openai" /** * Layer replacements applied when the server is built in simulation mode. * - * The server merges these into the app node build when `OPENCODE_SIMULATION` + * The server merges these into the app node build when `OPENCODE_SIMULATE` * is enabled, via a dynamic import so this module is never loaded eagerly. * - * - Filesystem: in-memory tree rooted at `OPENCODE_SIMULATION_ROOT` (the real, - * empty anchor directory the runner created and chdir'd into). Everything - * under the root lives in memory; paths outside it fail loudly. * - Network: all outbound HTTP resolves against the simulated route table; * unknown destinations are denied. The driver-answered OpenAI endpoint is * registered here as the first route. * - * Loading this module also starts the backend simulation control WebSocket, - * which drivers connect to directly for LLM exchange control and network - * inspection (standalone topology; also the headless-simulation interface). */ SimulationNetwork.register(SimulationOpenAI.route) @@ -30,11 +22,11 @@ SimulationNetwork.register(SimulationOpenAI.route) // an empty catalog; providers come from seeded config instead. SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) -SimulationControl.start() +export function startDriveServer() { + return SimulationControl.start(DriveManifest.resolve().endpoints.backend) +} export const simulationReplacements: LayerNode.Replacements = [ - [filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })], - [FSUtil.node, SimulationFSUtil.node], [httpClient, SimulationNetwork.layer], ] diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts index 97b7b71b1f..638f80c1d0 100644 --- a/packages/simulation/src/backend/llm-exchange.ts +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -18,7 +18,13 @@ import { Effect, Queue } from "effect" export type Item = | { readonly type: "textDelta"; readonly text: string } | { readonly type: "reasoningDelta"; readonly text: string } - | { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown } + | { + readonly type: "toolCall" + readonly index: number + readonly id: string + readonly name: string + readonly input: unknown + } | { readonly type: "raw"; readonly chunk: unknown } export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts index c13e7772ef..fb58a44724 100644 --- a/packages/simulation/src/backend/openai.ts +++ b/packages/simulation/src/backend/openai.ts @@ -30,7 +30,11 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { { delta: { tool_calls: [ - { index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } }, + { + index: item.index, + id: item.id, + function: { name: item.name, arguments: JSON.stringify(item.input) }, + }, ], }, }, diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index d03e4a5f75..5819d94035 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,8 +1,11 @@ +import { mkdir } from "node:fs/promises" +import { tmpdir } from "node:os" +import { extname, join, resolve } from "node:path" import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" -import { SimulationTrace } from "./trace" +import { SimulationPng } from "./png" export type Action = SimulationProtocol.Frontend.Action export type Element = SimulationProtocol.Frontend.Element @@ -47,7 +50,7 @@ function hit(renderer: CliRenderer, renderable: Renderable) { /** * Builds the harness the simulation server drives. * - * When the renderer is the fake simulation renderer, its TestRendererSetup + * When the renderer is the headless simulation renderer, its TestRendererSetup * provides the supported testing APIs. For the visible terminal renderer the * harness falls back to `requestRender` + `idle` and reading the private * `currentRenderBuffer`. @@ -91,57 +94,57 @@ export function elements(renderer: CliRenderer): Element[] { .filter((element) => element.focusable || element.clickable || element.editor) } -export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] { - const items = elements(renderer) - return [ - ...(renderer.currentFocusedEditor - ? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[]) - : []), - ...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })), - ...items - .filter((item) => item.clickable) - .map((item) => ({ - type: "click" as const, - target: item.num, - x: Math.floor(item.x + item.width / 2), - y: Math.floor(item.y + item.height / 2), - })), - { type: "pressArrow", direction: "down" }, - { type: "pressArrow", direction: "up" }, - ] -} - export function state(harness: Harness) { return { - screen: harness.screen(), focused: { renderable: harness.renderer.currentFocusedRenderable?.num, editor: Boolean(harness.renderer.currentFocusedEditor), }, elements: elements(harness.renderer), - actions: actions(harness.renderer), } } +export async function screenshot(harness: Harness, name?: string) { + await harness.renderOnce() + const image = SimulationPng.screenshot(harness.renderer) + const filename = name ?? `screenshot-${crypto.randomUUID()}` + if ( + !filename || + filename.includes("/") || + filename.includes("\\") || + extname(filename) + ) + throw new Error("screenshot name must not contain a path or extension") + const directory = resolve( + process.env.OPENCODE_DRIVE_MEDIA_DIR ?? + join(tmpdir(), "opencode-drive", "output"), + ) + await mkdir(directory, { recursive: true }) + const path = join(directory, `${filename}.png`) + await Bun.write(path, image.data) + return path +} + export async function execute(harness: Harness, action: Action) { - SimulationTrace.add("ui.action", { action }) switch (action.type) { - case "typeText": + case "ui.type": await harness.mockInput.typeText(action.text) break - case "pressKey": + case "ui.press": harness.mockInput.pressKey(action.key, action.modifiers) break - case "pressEnter": + case "ui.enter": harness.mockInput.pressEnter() break - case "pressArrow": + case "ui.arrow": harness.mockInput.pressArrow(action.direction) break - case "focus": - all(harness.renderer.root).find((item) => item.num === action.target)?.focus() + case "ui.focus": + all(harness.renderer.root) + .find((item) => item.num === action.target) + ?.focus() break - case "click": + case "ui.click": await harness.mockMouse.click(action.x, action.y) break } diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts new file mode 100644 index 0000000000..4f7b11c1a0 --- /dev/null +++ b/packages/simulation/src/frontend/png.ts @@ -0,0 +1,91 @@ +import { fileURLToPath } from "node:url" +import { GlobalFonts, createCanvas } from "@napi-rs/canvas" +import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core" + +const CellWidth = 10 +const CellHeight = 20 +const FontSize = 16 +const FontFamily = "OpenCode Mono" + +for (const file of [ + "adwaita-mono-latin-400-normal.woff2", + "adwaita-mono-latin-700-normal.woff2", + "adwaita-mono-latin-400-italic.woff2", + "adwaita-mono-latin-700-italic.woff2", +]) { + GlobalFonts.registerFromPath( + fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)), + FontFamily, + ) +} + +export function screenshot(renderer: CliRenderer) { + return screenshotFrame({ + cols: renderer.currentRenderBuffer.width, + rows: renderer.currentRenderBuffer.height, + cursor: [0, 0], + lines: renderer.currentRenderBuffer.getSpanLines(), + }) +} + +export function screenshotFrame(frame: CapturedFrame) { + const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight) + const context = canvas.getContext("2d") + context.fillStyle = "#080808" + context.fillRect(0, 0, canvas.width, canvas.height) + context.textBaseline = "top" + + frame.lines.forEach((line, row) => { + let column = 0 + line.spans.forEach((span) => { + const attributes = span.attributes & 0xff + const inverse = Boolean(attributes & TextAttributes.INVERSE) + const hidden = Boolean(attributes & TextAttributes.HIDDEN) + const foreground = inverse ? span.bg : span.fg + const background = inverse ? span.fg : span.bg + const chars = [...span.text] + let remaining = span.width + + chars.forEach((char, index) => { + const cells = Math.max(1, remaining - (chars.length - index - 1)) + if (background.a) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight) + } + if (!hidden && char.codePointAt(0) !== 0x0a00) { + context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1) + context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` + context.fillText(char, column * CellWidth, row * CellHeight + 1) + if (attributes & TextAttributes.UNDERLINE) { + context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1) + } + if (attributes & TextAttributes.STRIKETHROUGH) { + context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1) + } + } + column += cells + remaining -= cells + }) + while (remaining-- > 0) { + if (background.a) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight) + } + column++ + } + }) + }) + + return { + width: canvas.width, + height: canvas.height, + data: canvas.toBuffer("image/png"), + } +} + +function color(value: RGBA, opacity = 1) { + const [red, green, blue, alpha] = value.toInts() + return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` +} + +export * as SimulationPng from "./png" diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index 03e182cadc..43947d9ea6 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -1,21 +1,43 @@ import type { CliRenderer, CliRendererConfig } from "@opentui/core" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" +import { Timeline } from "../recording" const setups = new WeakMap() +const recordings = new WeakMap() /** - * Creates the fake simulation renderer: a real CliRenderer backed by an - * in-memory screen buffer instead of a terminal. The TestRendererSetup is - * kept module-side (keyed by renderer) so the harness can use the supported - * testing APIs without app code carrying it around. + * Creates a headless renderer with optional recording: a real CliRenderer + * backed by an in-memory screen buffer. The TestRendererSetup is kept + * module-side so the harness can use supported testing APIs without app + * code carrying it around. */ -export async function create(options: CliRendererConfig): Promise { +export async function create(options: CliRendererConfig, path?: string): Promise { + if (!path) { + const setup = await createTestRenderer({ + ...options, + width: 100, + height: 40, + }) + setups.set(setup.renderer, setup) + return setup.renderer + } + const recording = await Timeline.create(path, 100, 40) const setup = await createTestRenderer({ ...options, - width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100, - height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40, + width: 100, + height: 40, + stdout: recording as unknown as NodeJS.WriteStream, + bufferedOutput: "stdout", + onDestroy: () => { + void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`)) + options.onDestroy?.() + }, + }).catch(async (error) => { + await recording.finish().catch(() => undefined) + throw error }) setups.set(setup.renderer, setup) + recordings.set(setup.renderer, recording) return setup.renderer } @@ -23,4 +45,10 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined { return setups.get(renderer) } +export function finish(renderer: CliRenderer) { + const recording = recordings.get(renderer) + if (!recording) throw new Error("UI recording is not available") + return recording.finish() +} + export * as SimulationRenderer from "./renderer" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index c6a6c8784c..b878bdd761 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,106 +1,79 @@ import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" -import { SimulationTrace } from "./trace" - -const DefaultPort = 40900 -const MaxPortAttempts = 100 export interface Server { readonly url: string readonly stop: () => void } -function isEnabled() { - return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true" -} - -function isPortUnavailable(error: unknown) { - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() - return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use") -} - -function actionParam(params: unknown) { - return SimulationProtocol.Frontend.decodeActionParams(params).action -} - function parseRequest(input: string | Buffer) { - return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) + return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { - switch (request.method) { - case "ui.state": { - const result = SimulationActions.state(harness) - SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length }) - return result - } - case "ui.action": - return SimulationActions.execute(harness, actionParam(request.params)) - case "ui.render": { - await harness.renderOnce() - const result = SimulationActions.state(harness) - SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length }) - return result - } - case "trace.list": - return { records: SimulationTrace.list() } - case "trace.clear": - SimulationTrace.clear() - return { cleared: true } - case "trace.export": - return SimulationTrace.exportTrace() - } - throw new Error(`Unknown simulation method: ${request.method}`) -} - -function serve( +async function handle( harness: Harness, - port = DefaultPort, - attempts = MaxPortAttempts, -): Bun.Server<{ readonly simulation: true }> { - try { - return Bun.serve<{ readonly simulation: true }>({ - hostname: "127.0.0.1", - port, - fetch(request, server) { - if (server.upgrade(request, { data: { simulation: true } })) return undefined - return new Response("opencode simulation websocket", { status: 426 }) - }, - websocket: { - open() { - SimulationTrace.add("control.connect") - }, - close() { - SimulationTrace.add("control.disconnect") - }, - async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined - try { - request = parseRequest(message) - const result = await handle(harness, request) - const next = SimulationProtocol.JsonRpc.success(request.id, result) - if (next) socket.send(JSON.stringify(next)) - } catch (error) { - socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) - } - }, - }, - }) - } catch (error) { - if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error - return serve(harness, port + 1, attempts - 1) + request: SimulationProtocol.Frontend.Request, + finishRecording?: () => Promise, +) { + switch (request.method) { + case "ui.screenshot": + return SimulationActions.screenshot(harness, request.params?.name) + case "ui.state": { + return SimulationActions.state(harness) + } + case "ui.recording.finish": + if (!finishRecording) throw new Error("UI recording is not available") + return finishRecording() + case "ui.type": + return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) + case "ui.enter": + return SimulationActions.execute(harness, { type: "ui.enter" }) + case "ui.press": + return SimulationActions.execute(harness, { + type: "ui.press", + key: request.params.key, + modifiers: request.params.modifiers, + }) + case "ui.arrow": + return SimulationActions.execute(harness, { type: "ui.arrow", direction: request.params.direction }) + case "ui.focus": + return SimulationActions.execute(harness, { type: "ui.focus", target: request.params.target }) + case "ui.click": + return SimulationActions.execute(harness, { + type: "ui.click", + target: request.params.target, + x: request.params.x, + y: request.params.y, + }) } } -export function start(harness: Harness): Server | undefined { - if (!isEnabled()) return - const server = serve(harness) - const url = `ws://${server.hostname}:${server.port}` - SimulationTrace.add("control.start", { url }) +export function start(harness: Harness, endpoint: string, finishRecording?: () => Promise): Server { + const url = new URL(endpoint) + const server = Bun.serve<{ readonly drive: true }>({ + hostname: url.hostname, + port: Number(url.port), + fetch(request, server) { + if (server.upgrade(request, { data: { drive: true } })) return undefined + return new Response("opencode drive ui websocket", { status: 426 }) + }, + websocket: { + async message(socket, message) { + let request: SimulationProtocol.Frontend.Request | undefined + try { + request = parseRequest(message) + const result = await handle(harness, request, finishRecording) + const next = SimulationProtocol.JsonRpc.success(request.id, result) + if (next) socket.send(JSON.stringify(next)) + } catch (error) { + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) + } + }, + }, + }) return { - url, + url: endpoint, stop: () => { - SimulationTrace.add("control.stop", { url }) server.stop(true) }, } diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index ff24e0459d..2a08d2dbef 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -1,27 +1,31 @@ import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core" +import { DriveManifest } from "../manifest" import { SimulationActions } from "./actions" import { SimulationRenderer } from "./renderer" import { SimulationServer } from "./server" /** - * Simulation-mode renderer entry point. + * Drive-mode renderer entry point. * - * Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the - * normal visible renderer otherwise) and starts the simulation control + * Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal + * visible renderer otherwise) and starts the UI control * server against it. The server stops when the renderer is destroyed, so the * caller only manages the renderer lifecycle. */ -export async function createSimulation(options: CliRendererConfig): Promise { - const renderer = - process.env.OPENCODE_SIMULATION_RENDERER === "fake" - ? await SimulationRenderer.create(options) - : await createCliRenderer(options) - const server = SimulationServer.start(SimulationActions.createHarness(renderer)) - if (server) { - process.stderr.write(`opencode simulation websocket: ${server.url}\n`) - renderer.once("destroy", () => server.stop()) - } +export async function create(options: CliRendererConfig): Promise { + const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" + const manifest = DriveManifest.resolve() + const renderer = headless + ? await SimulationRenderer.create(options, manifest.recording?.timeline) + : await createCliRenderer(options) + const server = SimulationServer.start( + SimulationActions.createHarness(renderer), + manifest.endpoints.ui, + headless && manifest.recording ? () => SimulationRenderer.finish(renderer) : undefined, + ) + process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) + renderer.once("destroy", () => server.stop()) return renderer } -export * as Simulation from "./simulation" +export * as Drive from "./simulation" diff --git a/packages/simulation/src/frontend/trace.ts b/packages/simulation/src/frontend/trace.ts deleted file mode 100644 index b0914faace..0000000000 --- a/packages/simulation/src/frontend/trace.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type TraceRecord = { - readonly id: number - readonly time: string - readonly type: string - readonly data?: unknown -} - -const records: TraceRecord[] = [] -let nextID = 0 - -export function add(type: string, data?: unknown) { - const record = { - id: ++nextID, - time: new Date().toISOString(), - type, - ...(data === undefined ? {} : { data }), - } satisfies TraceRecord - records.push(record) - return record -} - -export function list() { - return [...records] -} - -export function clear() { - records.length = 0 - nextID = 0 -} - -export function exportTrace() { - return { - records: list(), - } -} - -export * as SimulationTrace from "./trace" diff --git a/packages/simulation/src/manifest.ts b/packages/simulation/src/manifest.ts new file mode 100644 index 0000000000..5544d23b70 --- /dev/null +++ b/packages/simulation/src/manifest.ts @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { isAbsolute, join } from "node:path" + +export interface Manifest { + readonly endpoints: { + readonly ui: string + readonly backend: string + } + readonly recording?: { + readonly timeline: string + } +} + +export const defaults: Manifest = { + endpoints: { + ui: "ws://127.0.0.1:40900", + backend: "ws://127.0.0.1:40950", + }, +} + +export function resolve() { + const name = process.env.OPENCODE_DRIVE + if (!name) throw new Error("OPENCODE_DRIVE must contain a drive instance name") + if (name === "1") return defaults + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`Invalid drive instance name: ${name}`) + + const directory = + process.env.DRIVE_REGISTRY_DIR ?? + join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances") + const file = join(directory, `${name}.json`) + if (!existsSync(file)) throw new Error(`Drive manifest not found: ${file}`) + + const manifest: unknown = JSON.parse(readFileSync(file, "utf8")) + if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) + validateEndpoint(manifest.endpoints.ui, "ui") + validateEndpoint(manifest.endpoints.backend, "backend") + if (manifest.recording && !isAbsolute(manifest.recording.timeline)) { + throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`) + } + return manifest +} + +function isManifest(value: unknown): value is Manifest { + if (typeof value !== "object" || value === null || !("endpoints" in value)) return false + if (typeof value.endpoints !== "object" || value.endpoints === null) return false + return "ui" in value.endpoints && "backend" in value.endpoints +} + +function validateEndpoint(value: string, name: string) { + const endpoint = new URL(value) + const port = Number(endpoint.port) + if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1) { + throw new Error(`Invalid drive ${name} endpoint: ${value}`) + } +} + +export * as DriveManifest from "./manifest" diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index b05f24d501..a2b9fa3c29 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -4,9 +4,12 @@ const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null]) type Json = Schema.Schema.Type export namespace JsonRpc { - export const Request = Schema.Struct({ + export const RequestFields = { jsonrpc: Schema.Literal("2.0"), id: Schema.optional(JsonRpcID), + } + export const Request = Schema.Struct({ + ...RequestFields, method: Schema.String, params: Schema.optional(Schema.Json), }) @@ -56,12 +59,12 @@ export namespace Frontend { export interface KeyModifiers extends Schema.Schema.Type {} export const Action = Schema.Union([ - Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), - Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), - Schema.Struct({ type: Schema.Literal("pressEnter") }), - Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), - Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), - Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), + Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), + Schema.Struct({ type: Schema.Literal("ui.enter") }), + Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), + Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }), + Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), ]) export type Action = Schema.Schema.Type @@ -80,37 +83,69 @@ export namespace Frontend { export interface Element extends Schema.Schema.Type {} export const State = Schema.Struct({ - screen: Schema.String, focused: Schema.Struct({ renderable: Schema.optional(Schema.Number), editor: Schema.Boolean, }), elements: Schema.Array(Element), - actions: Schema.Array(Action), }) export interface State extends Schema.Schema.Type {} - export const ActionParams = Schema.Struct({ action: Action }) - export interface ActionParams extends Schema.Schema.Type {} - export const decodeActionParams = Schema.decodeUnknownSync(ActionParams) + export const Screenshot = Schema.String + export type Screenshot = Schema.Schema.Type - export const TraceRecord = Schema.Struct({ - id: Schema.Number, - time: Schema.String, - type: Schema.String, - data: Schema.optional(Schema.Json), - }) - export interface TraceRecord extends Schema.Schema.Type {} + export const RecordingFinish = Schema.String + export type RecordingFinish = Schema.Schema.Type - export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) }) - export interface TraceList extends Schema.Schema.Type {} + export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) }) + export interface ScreenshotParams extends Schema.Schema.Type {} + + export const TypeParams = Schema.Struct({ text: Schema.String }) + export interface TypeParams extends Schema.Schema.Type {} + + export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) }) + export interface PressParams extends Schema.Schema.Type {} + + export const ArrowParams = Schema.Struct({ direction: Schema.Literals(["up", "down", "left", "right"]) }) + export interface ArrowParams extends Schema.Schema.Type {} + + export const FocusParams = Schema.Struct({ target: Schema.Number }) + export interface FocusParams extends Schema.Schema.Type {} + + export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number }) + export interface ClickParams extends Schema.Schema.Type {} + + export const Request = Schema.Union([ + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literal("ui.screenshot"), + params: Schema.optional(ScreenshotParams), + }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]), + }), + ]) + export type Request = Schema.Schema.Type + export const decodeRequest = Schema.decodeUnknownSync(Request) } export namespace Backend { export const Item = Schema.Union([ Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), - Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }), + Schema.Struct({ + type: Schema.Literal("toolCall"), + index: Schema.Number, + id: Schema.String, + name: Schema.String, + input: Schema.Json, + }), Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }), ]) export type Item = Schema.Schema.Type @@ -130,6 +165,18 @@ export namespace Backend { export const DisconnectParams = Schema.Struct({ id: Schema.String }) export interface DisconnectParams extends Schema.Schema.Type {} + export const Request = Schema.Union([ + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["llm.attach", "llm.pending"]), + }), + ]) + export type Request = Schema.Schema.Type + export const decodeRequest = Schema.decodeUnknownSync(Request) + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) export interface OpenedExchange extends Schema.Schema.Type {} @@ -140,10 +187,6 @@ export namespace Backend { matched: Schema.Boolean, }) export interface NetworkLogEntry extends Schema.Schema.Type {} - - export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) - export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) - export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams) } export * as SimulationProtocol from "./index" diff --git a/packages/simulation/src/recording.ts b/packages/simulation/src/recording.ts new file mode 100644 index 0000000000..6d0b3a6bd5 --- /dev/null +++ b/packages/simulation/src/recording.ts @@ -0,0 +1,115 @@ +import { createWriteStream, type WriteStream } from "node:fs" +import { mkdir } from "node:fs/promises" +import { dirname } from "node:path" +import { Writable } from "node:stream" +import { finished } from "node:stream/promises" +import { Schema } from "effect" + +export const Header = Schema.Struct({ + type: Schema.Literal("header"), + version: Schema.Literal(1), + cols: Schema.Number, + rows: Schema.Number, + encoding: Schema.Literal("base64"), +}) +export interface Header extends Schema.Schema.Type {} + +export const Output = Schema.Struct({ + type: Schema.Literal("output"), + at_ms: Schema.Number, + data: Schema.String, +}) +export interface Output extends Schema.Schema.Type {} + +export const Event = Schema.Union([Header, Output]) +export type Event = Schema.Schema.Type + +export class Timeline extends Writable { + readonly isTTY = true + readonly path: string + readonly columns: number + readonly rows: number + private readonly output: WriteStream + private readonly started = performance.now() + private readonly timestamps: number[] = [] + private done?: Promise + + private constructor(path: string, cols: number, rows: number, output: WriteStream) { + super() + this.path = path + this.columns = cols + this.rows = rows + this.output = output + // finish() reports stream failures; keep Writable from also throwing them process-wide. + this.on("error", () => {}) + output.on("error", (error) => this.destroy(error)) + } + + static async create(path: string, cols: number, rows: number) { + await mkdir(dirname(path), { recursive: true }) + const output = createWriteStream(path) + const timeline = new Timeline(path, cols, rows, output) + await new Promise((resolve, reject) => { + output.write( + `${JSON.stringify({ type: "header", version: 1, cols, rows, encoding: "base64" } satisfies Header)}\n`, + (error) => (error ? reject(error) : resolve()), + ) + }) + return timeline + } + + getColorDepth() { + return 24 + } + + override write(chunk: unknown, callback?: (error?: Error | null) => void): boolean + override write(chunk: unknown, encoding: BufferEncoding, callback?: (error?: Error | null) => void): boolean + override write( + chunk: unknown, + encoding?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) { + if (!this.writableEnded) { + this.timestamps.push(this.elapsed()) + if (typeof encoding === "function") return super.write(chunk, encoding) + if (encoding === undefined) return super.write(chunk, callback) + return super.write(chunk, encoding, callback) + } + const done = typeof encoding === "function" ? encoding : callback + queueMicrotask(() => done?.(null)) + return true + } + + override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.writeOutput(chunk, this.timestamps.shift() ?? this.elapsed(), callback) + } + + override _final(callback: (error?: Error | null) => void) { + this.writeOutput(Buffer.alloc(0), this.elapsed(), (error) => { + if (error) return callback(error) + this.output.end(callback) + }) + } + + finish() { + if (this.done) return this.done + this.end() + this.done = finished(this).then(() => this.path) + return this.done + } + + private elapsed() { + return Math.max(0, Math.round(performance.now() - this.started)) + } + + private writeOutput(data: Buffer, at_ms: number, callback: (error?: Error | null) => void) { + const event = { + type: "output", + at_ms, + data: data.toString("base64"), + } satisfies Output + this.output.write(`${JSON.stringify(event)}\n`, callback) + } +} + +export * as SimulationRecording from "./recording" diff --git a/packages/simulation/test/recording.test.ts b/packages/simulation/test/recording.test.ts new file mode 100644 index 0000000000..d87453bb79 --- /dev/null +++ b/packages/simulation/test/recording.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { SimulationRenderer } from "../src/frontend/renderer" +import { Timeline, type Event } from "../src/recording" + +test("streams ANSI chunks into a versioned JSONL timeline", async () => { + const directory = await mkdtemp(join(tmpdir(), "simulation-recording-")) + const path = join(directory, "nested", "timeline.jsonl") + + try { + const timeline = await Timeline.create(path, 80, 24) + await new Promise((resolve, reject) => { + timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve())) + }) + expect(await timeline.finish()).toBe(path) + await new Promise((resolve) => timeline.write(Buffer.from("ignored"), () => resolve())) + + const events = (await Bun.file(path).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events[0]).toEqual({ type: "header", version: 1, cols: 80, rows: 24, encoding: "base64" }) + expect(events[1]?.type).toBe("output") + if (events[1]?.type !== "output") throw new Error("Missing output event") + expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello") + expect(events[1].at_ms).toBeGreaterThanOrEqual(0) + expect(events.at(-1)).toMatchObject({ type: "output", data: "" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test("captures native renderer output and finishes on destroy", async () => { + const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-")) + const path = join(directory, "timeline.jsonl") + const renderer = await SimulationRenderer.create({}, path) + + try { + await SimulationRenderer.setupFor(renderer)?.renderOnce() + renderer.destroy() + expect(await SimulationRenderer.finish(renderer)).toBe(path) + + const events = (await Bun.file(path).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events.some((event) => event.type === "output")).toBe(true) + } finally { + if (!renderer.isDestroyed) renderer.destroy() + await SimulationRenderer.finish(renderer) + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 67d16c1c48..cbb398a55d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -205,9 +205,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, } satisfies CliRendererConfig - if (!!process.env.OPENCODE_SIMULATION) { - const { Simulation } = await import("@opencode-ai/simulation/frontend") - return Simulation.createSimulation(options) + if (process.env.OPENCODE_DRIVE) { + const { Drive } = await import("@opencode-ai/simulation/frontend") + return Drive.create(options) } return createCliRenderer(options) @@ -500,7 +500,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } if (route.data.type === "session") { - const session = sync.session.get(route.data.sessionID) + const session = data.session.get(route.data.sessionID) if (!session || isDefaultTitle(session.title)) { renderer.setTerminalTitle("OpenCode") return @@ -559,13 +559,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi route.navigate({ type: "session", sessionID: match }) return } - void sdk.client.session.fork({ sessionID: match }).then((result) => { - if (result.data?.id) { - route.navigate({ type: "session", sessionID: result.data.id }) - return - } - toast.show({ message: "Failed to fork session", variant: "error" }) - }) + void sdk.api.session + .fork({ sessionID: match }) + .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .catch(toast.error) }) .catch(toast.error) }) @@ -577,13 +574,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi createEffect(() => { if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return forked = true - void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { - if (result.data?.id) { - route.navigate({ type: "session", sessionID: result.data.id }) - } else { - toast.show({ message: "Failed to fork session", variant: "error" }) - } - }) + void sdk.api.session + .fork({ sessionID: args.sessionID }) + .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .catch(toast.error) }) const connected = useConnected() @@ -1074,7 +1068,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) event.on("session.deleted", (evt) => { - if (route.data.type === "session" && route.data.sessionID === evt.data.info.id) { + if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) { route.navigate({ type: "home" }) toast.show({ variant: "info", @@ -1152,11 +1146,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return render({ params: route.data.data }) }) - // Suppress the full-screen reconnecting overlay for transient disconnects (initial startup, host - // reload, sub-second event-stream blips). After the first successful connect, show it only once the - // connection has been lost for a full second; before the first connect give a longer grace period so - // startup never flashes it, but a server that dies before ever connecting still surfaces instead of - // leaving a silent empty app. Hide it immediately the moment status leaves "connecting". + // Suppress the full-screen overlay for transient startup and event-stream retry states. + // Initial connection gets a longer grace period; retries surface more quickly. const [showReconnecting, setShowReconnecting] = createSignal(false) let reconnectTimer: ReturnType | undefined createEffect(() => { @@ -1164,7 +1155,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi clearTimeout(reconnectTimer) reconnectTimer = undefined } - if (sdk.connection.status() !== "connecting") { + const status = sdk.connection.status() + if (status === "connected") { setShowReconnecting(false) return } @@ -1173,7 +1165,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi reconnectTimer = undefined setShowReconnecting(true) }, - sdk.connection.connectedOnce() ? 1000 : 5000, + status === "reconnecting" ? 1000 : 5000, ).unref() }) onCleanup(() => { diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 52aba6d0e3..1b962142fc 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -180,7 +180,7 @@ function KeyMethod(props: { onConfirm={(key) => { if (!key) return void sdk.api.integration - .connectKey({ + .connect.key({ integrationID: props.integration.id, location: location(data), key, @@ -219,7 +219,7 @@ function OAuthStarting(props: { onMount(() => { void sdk.api.integration - .connectOauth({ + .connect.oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -288,7 +288,7 @@ function OAuthAuto(props: { const poll = () => { void sdk.api.integration - .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) + .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -314,7 +314,7 @@ function OAuthAuto(props: { onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -344,7 +344,7 @@ function OAuthCode(props: { onCleanup(() => { if (settled) return - void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -354,7 +354,7 @@ function OAuthCode(props: { onConfirm={(code) => { if (!code) return void sdk.api.integration - .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) + .attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 405f2e0693..e0beff09ed 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -19,8 +19,9 @@ import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise" import { useRoute } from "../context/route" +import { DialogProjectCopyName } from "./dialog-project-copy-name" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { @@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (await removedCurrent(deletingCurrent)) return } + async function create() { + const name = await DialogProjectCopyName.show(dialog) + if (name === null) return + props.onSelect({ type: "new", name }) + } + const fullHeight = createMemo(() => Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)), ) @@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { { command: "dialog.move_session.new", title: "new", - onTrigger: () => props.onSelect({ type: "new" }), + onTrigger: () => void create(), }, { command: "dialog.move_session.delete", diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx new file mode 100644 index 0000000000..7ac357e1c8 --- /dev/null +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -0,0 +1,95 @@ +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 { useTheme } from "../context/theme" +import { useBindings, useCommandShortcut } from "../keymap" +import { useDialog, type DialogContext } from "../ui/dialog" + +export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { + const dialog = useDialog() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const generateShortcut = useCommandShortcut("dialog.project_copy.generate") + const [inputTarget, setInputTarget] = createSignal() + let input: InputRenderable + + function generate() { + input.value = Slug.create() + input.gotoLineEnd() + } + + function confirm() { + props.onConfirm(slugify(input.value) || Slug.create()) + } + + useBindings(() => ({ + target: inputTarget, + enabled: inputTarget() !== undefined, + priority: 1, + commands: [ + { + name: "dialog.project_copy.generate", + title: "Generate project copy name", + category: "Dialog", + run: generate, + }, + ], + bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"), + })) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + if (!input || input.isDestroyed) return + input.focus() + }, 1) + }) + + return ( + + + + Name project copy + + dialog.clear()}> + esc + + + { + input = value + setInputTarget(value) + }} + onSubmit={confirm} + placeholder="Project copy name" + placeholderColor={theme.textMuted} + textColor={theme.text} + focusedTextColor={theme.text} + cursorColor={theme.text} + /> + + + enter submit + + + {generateShortcut()} generate one + + + + ) +} + +DialogProjectCopyName.show = (dialog: DialogContext) => + new Promise((resolve) => { + dialog.replace(() => , () => resolve(null)) + }) + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 9232acf6d9..025c0968b8 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, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount } from "solid-js" import path from "path" -import type { SessionV2Info } from "@opencode-ai/sdk/v2" +import type { SessionInfo } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" @@ -15,6 +15,7 @@ import { useToast } from "../ui/toast" import { useCommandShortcut } from "../keymap" import { DialogSessionRename } from "./dialog-session-rename" import { Spinner } from "./spinner" +import { errorMessage } from "../util/error" export function DialogSessionList() { const dialog = useDialog() @@ -26,8 +27,10 @@ export function DialogSessionList() { const local = useLocal() const toast = useToast() const [search, setSearch] = createDebouncedSignal("", 150) + const [toDelete, setToDelete] = createSignal() const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch9 = useCommandShortcut("session.quick_switch.9") + const deleteHint = useCommandShortcut("session.delete") const [searchResults] = createResource(search, async (query) => { if (!query) return @@ -41,7 +44,7 @@ export function DialogSessionList() { workspace: location.workspaceID, }) // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types. - return { query, sessions: structuredClone(response.data) as SessionV2Info[] } + return { query, sessions: structuredClone(response.data) as SessionInfo[] } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) @@ -74,21 +77,22 @@ export function DialogSessionList() { const pinnedSet = new Set(pinned) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) - const option = (session: SessionV2Info, category: string) => { + const option = (session: SessionInfo, category: string) => { const directory = session.location.directory const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" const slot = slotByID.get(session.id) + const deleting = toDelete() === session.id return { - title: session.title, + title: deleting ? `Press ${deleteHint()} again to confirm` : session.title, value: session.id, category, footer, - gutter: - data.session.family(session.id).some((id) => data.session.status(id) === "running") - ? () => - : slot === undefined - ? undefined - : () => {slot}, + bg: deleting ? theme.error : undefined, + gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") + ? () => + : slot === undefined + ? undefined + : () => {slot}, } } @@ -104,9 +108,6 @@ export function DialogSessionList() { onMount(() => dialog.setSize("large")) - const unavailable = (feature: string) => - toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) - return ( setToDelete(undefined)} onSelect={(option) => { route.navigate({ type: "session", sessionID: option.value }) dialog.clear() @@ -127,7 +129,20 @@ export function DialogSessionList() { { command: "session.delete", title: "delete", - onTrigger: () => unavailable("Deleting"), + onTrigger: (option: { value: string }) => { + if (toDelete() !== option.value) { + setToDelete(option.value) + return + } + void sdk.client.v2.session.remove({ sessionID: option.value }, { throwOnError: true }).catch((error) => { + setToDelete(undefined) + toast.show({ + message: `Failed to delete session: ${errorMessage(error)}`, + variant: "error", + duration: 5000, + }) + }) + }, }, { command: "session.rename", diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 61c173a384..bb54dd8713 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -45,10 +45,10 @@ export function DialogSkill(props: DialogSkillProps) { return list.map((skill) => ({ title: skill.name.padEnd(maxWidth), description: skill.description?.replace(/\s+/g, " ").trim(), - value: skill.name, + value: skill.id, category: "Skills", onSelect: () => { - props.onSelect(skill.name) + props.onSelect(skill.id) dialog.clear() }, })) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 65bff40838..1b86dbe582 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -448,12 +448,12 @@ export function Autocomplete(props: { for (const skill of data.location.skill .list(location()) - ?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) { + ?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) { results.push({ - display: "/" + skill.name, + display: "/" + skill.id, description: skill.description, onSelect: () => { - const newText = "/" + skill.name + " " + const newText = "/" + skill.id + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7aac079b03..08db4ecc59 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,7 +37,7 @@ 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 { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" +import type { SessionInfo, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -57,6 +57,9 @@ import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" +import { lastAssistantWithUsage } from "../../util/session" + +registerOpencodeSpinner() registerOpencodeSpinner() @@ -164,9 +167,9 @@ export function Prompt(props: PromptProps) { const status = createMemo(() => data.session.status(props.sessionID ?? "")) const activeSubagents = createMemo(() => { if (!props.sessionID) return 0 - return data.session.family(props.sessionID).filter( - (id) => id !== props.sessionID && data.session.status(id) === "running", - ).length + return data.session + .family(props.sessionID) + .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length }) const runningShells = createMemo( () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, @@ -218,7 +221,10 @@ export function Prompt(props: PromptProps) { const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const workspace = usePromptWorkspace(props.sessionID) - const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) + const move = usePromptMove({ + projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), + sessionID: () => props.sessionID, + }) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() @@ -273,18 +279,20 @@ export function Prompt(props: PromptProps) { const usage = createMemo(() => { if (!props.sessionID) return - const session = sync.session.get(props.sessionID) - const msg = sync.data.message[props.sessionID] ?? [] - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + 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 = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + 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 ?? 0 + const cost = session.cost return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, @@ -955,13 +963,13 @@ export function Prompt(props: PromptProps) { if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false if (!store.prompt.text) return false - const agent = local.agent.current() - if (!agent) return false const trimmed = store.prompt.text.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true } + const agent = local.agent.current() + if (!agent) return false const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() @@ -991,7 +999,7 @@ export function Prompt(props: PromptProps) { const selectedWorkspace = workspace.selection() const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined - const directory = await move.getDirectory(store.prompt.text) + const directory = await move.getDirectory() if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) const location = data.location.default() @@ -1022,7 +1030,7 @@ export function Prompt(props: PromptProps) { sessionID = created.id // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types. - session = structuredClone(created) as SessionV2Info + session = structuredClone(created) as SessionInfo } const inputText = expandTrackedPastedText( @@ -1093,7 +1101,7 @@ export function Prompt(props: PromptProps) { } else if ( inputText.startsWith("/") && (data.location.skill.list(currentLocation()) ?? []).some( - (skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1), + (skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { move.startSubmit() @@ -1121,7 +1129,7 @@ export function Prompt(props: PromptProps) { }) } if (session?.revert) { - const error = await sdk.api.session.revertCommit({ sessionID }).then( + const error = await sdk.api.session.revert.commit({ sessionID }).then( () => undefined, (error) => error, ) @@ -1621,11 +1629,11 @@ export function Prompt(props: PromptProps) { {agentShortcut()} agents - - {paletteShortcut()} commands - + + {paletteShortcut()} commands + diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 9fdd5c9d6a..1139385034 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime" import { errorMessage } from "../../util/error" import { useDialog } from "../../ui/dialog" import { useSDK } from "../../context/sdk" -import { useSync } from "../../context/sync" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { useHomeSessionDestination } from "../../routes/home/session-destination" import { useProject } from "../../context/project" +import { useData } from "../../context/data" function moveReminderText(directory: string) { return `The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` @@ -18,38 +18,33 @@ function moveReminderText(directory: string) { export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { const dialog = useDialog() const sdk = useSDK() - const sync = useSync() const toast = useToast() const homeDestination = useHomeSessionDestination() const project = useProject() + const data = useData() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() - async function create(context?: string) { - const projectID = input.projectID() + async function create(name: string) { + const projectID = await resolveProjectID() if (!projectID) return setCreating(true) setProgress("Creating copy") try { - const generated = await sdk.client.experimental.projectCopy.generateName( - { projectID, context }, - { throwOnError: true }, - ) const result = await sdk.api.projectCopy.create({ projectID, location: { directory: project.instance.directory() || paths.cwd }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), - name: generated.data.name, + name, }) const directory = result.directory if (!directory) throw new Error("No project copy directory returned") - // Call a location-based route to make sure it's bootstrapped - // before moving on - await sdk.client.path.get({ directory }, { throwOnError: true }) + // Call a location-based route to make sure it's bootstrapped before moving on. + await sdk.api.location.get({ location: { directory } }) setProgress("Creating session") return directory @@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } } - function open() { - const projectID = input.projectID() - if (!projectID) return + async function open() { + const projectID = await resolveProjectID() + if (!projectID) { + toast.show({ message: "Unable to determine current project", variant: "error" }) + return + } const sessionID = input.sessionID() - const session = sessionID ? sync.session.get(sessionID) : undefined + const session = sessionID ? await resolveSession(sessionID) : undefined dialog.replace(() => ( string | undefined; sess (session ? { type: "directory", - directory: session.directory, - subdirectory: !!session.path, + directory: session.location.directory, + subdirectory: !!session.subpath, } : { type: "directory", @@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess )) } - function sessionContext(sessionID: string) { - const session = sync.session.get(sessionID) - const messages = (sync.data.message[sessionID] ?? []) - .slice(-6) - .map((message) => - [ - message.role + ":", - ...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])), - ].join(" "), - ) - return [session?.title, ...messages].filter(Boolean).join("\n") || undefined - } - async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { - const session = sync.session.get(sessionID) - const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined) + const session = await resolveSession(sessionID) + const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined) const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" if (!choice) return dialog.clear() - const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory + const directory = selection.type === "new" ? await create(selection.name) : selection.directory if (!directory) { setProgress(undefined) dialog.clear() @@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } setProgress("Moving session") try { - await sdk.client.experimental.controlPlane.moveSession( - { - sessionID, - destination: { directory }, - moveChanges: choice === "yes", - }, - { throwOnError: true }, - ) - await sdk.client.session - .promptAsync({ - sessionID, - directory, - noReply: true, - parts: [ - { - type: "text", - text: moveReminderText(directory), - synthetic: true, - }, - ], - }) + await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" }) + await sdk.api.session + .synthetic({ sessionID, text: moveReminderText(directory), resume: false }) .catch(() => undefined) dialog.clear() } catch (error) { @@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } } + async function resolveProjectID() { + const projectID = input.projectID() + if (projectID) return projectID + const sessionID = input.sessionID() + if (sessionID) return (await resolveSession(sessionID))?.projectID + return sdk.api.project + .current({ location: { directory: project.instance.directory() || paths.cwd } }) + .then((project) => project.id) + .catch(() => undefined) + } + + async function resolveSession(sessionID: string) { + const session = data.session.get(sessionID) + if (session) return session + await data.session.refresh(sessionID).catch(() => undefined) + return data.session.get(sessionID) + } + const pending = createMemo(() => Boolean(homeDestination?.destination())) const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") - async function getDirectory(context?: string) { + async function getDirectory() { const value = homeDestination?.destination() if (!value) return if (value.type === "directory") { return value.directory } - return await create(context) + return await create(value.name) } function startSubmit() { diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 7acb093a7b..0f97f8277d 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -207,6 +207,7 @@ export const Definitions = { "dialog.select.end": keybind("end", "Move to last dialog item"), "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"), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 7fdde0d49a..8ac669492a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,24 +1,29 @@ +// Client data layer: apply server events and cache API reads into a Solid store. +// Prefer straightforward projection. Do not add generation counters, stale-response +// merges, live/history overlays, or other race machinery here—last write wins. +// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. + import type { - AgentV2Info, - CommandV2Info, + AgentInfo, + CommandInfo, FormFormInfo, FormUrlInfo, IntegrationInfo, LocationRef, McpServer, - ModelV2Info, + ModelInfo, PermissionSavedInfo, PermissionV2Request, ProviderV2Info, ReferenceInfo, - SessionMessage, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, - SessionV2Info, + SessionInfo, Shell, - SkillV2Info, + SkillInfo, V2Event, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" @@ -33,28 +38,29 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") export type FormInfo = FormFormInfo | FormUrlInfo type LocationData = { - agent?: AgentV2Info[] - command?: CommandV2Info[] + agent?: AgentInfo[] + command?: CommandInfo[] integration?: IntegrationInfo[] mcp?: McpServer[] - model?: ModelV2Info[] + 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 - skill?: SkillV2Info[] + skill?: SkillInfo[] } type Data = { session: { - info: Record + info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. family: Record status: Record - message: Record + message: Record + input: Record permission: Record // Pending forms keyed by session ID. form: Record @@ -90,6 +96,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ family: {}, status: {}, message: {}, + input: {}, permission: {}, form: {}, }, @@ -104,17 +111,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() - let connectionGeneration = 0 - let statusChanges: Set | undefined let bootstrapping: Promise | undefined function setSessionStatus(sessionID: string, status: DataSessionStatus) { - statusChanges?.add(sessionID) setStore("session", "status", sessionID, status) } const message = { - update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { + update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( "session", "message", @@ -123,38 +127,40 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) }, - append(messages: SessionMessage[], index: Map, item: SessionMessage) { + append(messages: SessionMessageInfo[], index: Map, item: SessionMessageInfo) { if (index.has(item.id)) return index.set(item.id, messages.length) messages.push(item) }, - activeAssistant(messages: SessionMessage[]) { + activeAssistant(messages: SessionMessageInfo[]) { const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, - assistant(messages: SessionMessage[], index: Map, messageID: string) { + assistant(messages: SessionMessageInfo[], index: Map, messageID: string) { const position = index.get(messageID) const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - shell(messages: SessionMessage[], shellID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) + shell(messages: SessionMessageInfo[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID) return item?.type === "shell" ? item : undefined }, + compaction(messages: SessionMessageInfo[]) { + const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") + return item?.type === "compaction" ? item : undefined + }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { return assistant?.content.findLast( (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), ) }, - latestText(assistant: SessionMessageAssistant | undefined, textID: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID, - ) + latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") }, - latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + latestReasoning(assistant: SessionMessageAssistant | undefined) { return assistant?.content.findLast( - (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, ) }, } @@ -211,11 +217,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) } + function removeSession(sessionID: string) { + messageIndex.delete(sessionID) + setStore( + "session", + produce((draft) => { + delete draft.info[sessionID] + delete draft.status[sessionID] + delete draft.message[sessionID] + delete draft.input[sessionID] + delete draft.permission[sessionID] + delete draft.form[sessionID] + for (const [rootID, family] of Object.entries(draft.family)) { + const next = family.filter((id) => id !== sessionID) + if (next.length === 0) delete draft.family[rootID] + else draft.family[rootID] = next + } + }), + ) + } + function handleEvent(event: V2Event) { switch (event.type) { case "session.created": void result.session.refresh(event.data.sessionID) break + case "session.deleted": + removeSession(event.data.sessionID) + break + case "session.usage.updated": + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, { + cost: event.data.cost, + tokens: event.data.tokens, + }) + break case "catalog.updated": void Promise.all([ result.location.model.refresh(event.location), @@ -270,16 +306,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "title", event.data.title) break + case "session.moved": + if (store.session.info[event.data.sessionID]) { + setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location)) + setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) + } + break case "session.prompt.promoted": { - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return const existing = draft[position] - if (existing?.type === "user" && existing.metadata?.queued === true) { + if (existing?.type === "user" && store.session.input[event.data.sessionID]?.includes(event.data.inputID)) { existing.time.created = event.created - delete existing.metadata.queued - if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined draft.splice(position, 1) draft.push(existing) index.clear() @@ -287,9 +326,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return } }) + setStore( + "session", + "input", + event.data.sessionID, + (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), + ) break } case "session.prompt.admitted": + if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) + setStore("session", "input", event.data.sessionID, [ + ...(store.session.input[event.data.sessionID] ?? []), + event.data.inputID, + ]) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.inputID, @@ -297,7 +347,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, - metadata: { queued: true }, time: { created: event.created }, }) }) @@ -308,6 +357,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: messageIDFromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -317,53 +367,69 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "synthetic", - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, + metadata: event.data.metadata, time: { created: event.created }, }) }) break case "session.shell.started": - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), type: "shell", - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, + metadata: event.metadata, time: { created: event.created }, }) }) break case "session.shell.ended": - setSessionStatus(event.data.sessionID, "idle") message.update(event.data.sessionID, (draft) => { const match = message.shell(draft, event.data.shell.id) if (!match) return - match.shell = event.data.shell + match.status = event.data.shell.status + match.exit = event.data.shell.exit match.output = event.data.output match.time.completed = event.created }) break case "session.step.started": - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { - if (index.has(event.data.assistantMessageID)) return + const position = index.get(event.data.assistantMessageID) + const existing = position === undefined ? undefined : draft[position] + if (existing?.type === "assistant") { + existing.agent = event.data.agent + existing.model = event.data.model + existing.retry = undefined + existing.error = undefined + existing.finish = undefined + existing.time.completed = undefined + if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot } + return + } const currentAssistant = message.activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.created + if (currentAssistant) { + currentAssistant.retry = undefined + currentAssistant.time.completed = event.created + } message.append(draft, index, { id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, time: { created: event.created }, }) }) break - case "session.step.ended": - setSessionStatus(event.data.sessionID, "running") + case "session.step.ended": { message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -375,6 +441,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break + } case "session.step.failed": message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) @@ -382,32 +449,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.time.completed = event.created currentAssistant.finish = "error" currentAssistant.error = event.data.error + currentAssistant.retry = undefined + if (event.data.cost !== undefined && event.data.tokens !== undefined) { + currentAssistant.cost = event.data.cost + currentAssistant.tokens = event.data.tokens + } }) break case "session.text.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "text", - id: event.data.textID, text: "", }) }) break case "session.text.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.textID, - ) + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) if (match) match.text += event.data.delta }) break case "session.text.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.textID, - ) + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) if (match) match.text = event.data.text }) break @@ -418,7 +483,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: { status: "pending", input: "" }, + state: { status: "streaming", input: "" }, }) }) break @@ -428,7 +493,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input += event.data.delta + if (match?.state.status === "streaming") match.state.input += event.data.delta }) break case "session.tool.input.ended": @@ -437,7 +502,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input = event.data.text + if (match?.state.status === "streaming") match.state.input = event.data.text }) break case "session.tool.called": @@ -448,7 +513,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) if (!match) return match.time.ran = event.created - match.provider = event.data.provider + match.executed = event.data.executed + match.providerState = event.data.state match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break @@ -477,11 +543,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ content: [...event.data.content], result: event.data.result, } - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created }) break @@ -491,7 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return + if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return match.state = { status: "error", error: event.data.error, @@ -500,11 +563,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ content: match.state.status === "running" ? match.state.content : [], result: event.data.result, } - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created }) break @@ -512,41 +572,65 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "reasoning", - id: event.data.reasoningID, text: "", - providerMetadata: event.data.providerMetadata, + state: event.data.state, time: { created: event.created }, }) }) break case "session.reasoning.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.reasoningID, - ) + const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) if (match) match.text += event.data.delta }) break case "session.reasoning.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.reasoningID, - ) + const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) if (match) { match.text = event.data.text match.time = { created: match.time?.created ?? event.created, completed: event.created } - if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata + if (event.data.state !== undefined) match.state = event.data.state } }) break - case "session.retried": - case "session.compaction.started": + case "session.retry.scheduled": + message.update(event.data.sessionID, (draft, index) => { + const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) + if (!currentAssistant) return + currentAssistant.retry = { + attempt: event.data.attempt, + at: event.data.at, + error: event.data.error, + } + }) + break + case "session.execution.started": setSessionStatus(event.data.sessionID, "running") break - case "session.execution.settled": + case "session.compaction.admitted": + break + case "session.compaction.started": + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { + id: event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "running", + reason: event.data.reason, + summary: "", + recent: event.data.recent ?? "", + time: { created: event.created }, + }) + }) + break + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": setSessionStatus(event.data.sessionID, "idle") + message.update(event.data.sessionID, (draft) => { + const currentAssistant = message.activeAssistant(draft) + if (currentAssistant) currentAssistant.retry = undefined + }) break case "session.revert.staged": if (store.session.info[event.data.sessionID]) @@ -557,21 +641,48 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "revert", undefined) break case "session.revert.committed": - if (store.session.info[event.data.sessionID]) + if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "revert", undefined) + } + setStore( + "session", + "input", + event.data.sessionID, + (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to), + ) message.update(event.data.sessionID, (draft, index) => { - const position = draft.findIndex((item) => item.id >= event.data.messageID) + const position = draft.findIndex((item) => item.id >= event.data.to) if (position === -1) return for (const item of draft.splice(position)) index.delete(item.id) }) break case "session.compaction.delta": + message.update(event.data.sessionID, (draft) => { + const current = message.compaction(draft) + if (current?.status === "running") current.summary += event.data.text + }) break case "session.compaction.ended": message.update(event.data.sessionID, (draft, index) => { + 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", + 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, { id: messageIDFromEvent(event.id), type: "compaction", + status: "completed", reason: event.data.reason, summary: event.data.text, recent: event.data.recent, @@ -579,6 +690,29 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break + case "session.compaction.failed": + message.update(event.data.sessionID, (draft, index) => { + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "failed", + reason: event.data.reason ?? "manual", + error: event.data.error ?? { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + metadata: current?.type === "compaction" ? current.metadata : event.metadata, + time: current?.type === "compaction" ? current.time : { created: event.created }, + } + if (current?.type === "compaction") { + draft[position] = failed + return + } + message.append(draft, index, failed) + }) + break case "permission.v2.asked": if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break setStore("session", "permission", event.data.sessionID, [ @@ -656,20 +790,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const result = { on: sdk.event.on, listen: sdk.event.listen, - connection: { - status() { - return sdk.connection.status() - }, - attempt() { - return sdk.connection.attempt() - }, - error() { - return sdk.connection.error() - }, - connectedOnce() { - return sdk.connection.connectedOnce() - }, - }, session: { list() { return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated) @@ -686,6 +806,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status(sessionID: string) { return store.session.status[sessionID] ?? "idle" }, + input: { + list(sessionID: string) { + return store.session.input[sessionID] ?? [] + }, + has(sessionID: string, inputID: string) { + return store.session.input[sessionID]?.includes(inputID) ?? false + }, + }, async refresh(sessionID: string) { setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) @@ -703,21 +831,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return position === undefined ? undefined : messages?.[position] }, async refresh(sessionID: string) { - const live = [...(store.session.message[sessionID] ?? [])] setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) - const loaded = mutable( + const messages = mutable( (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, ).toReversed() - const loadedIDs = new Set(loaded.map((message) => message.id)) - const liveByID = new Map(live.map((message) => [message.id, message])) - const messages = [ - ...loaded.map((message) => { - if (message.type === "user") return message - return liveByID.get(message.id) ?? message - }), - ...live.filter((message) => !loadedIDs.has(message.id)), - ].toSorted((a, b) => a.time.created - b.time.created) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, messages) }, @@ -745,7 +863,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.project.permission[projectID] }, async refresh(projectID: string) { - setStore("project", "permission", projectID, mutable(await sdk.api.permission.listSaved({ projectID }))) + setStore("project", "permission", projectID, mutable(await sdk.api.permission.saved.list({ projectID }))) }, }, }, @@ -766,15 +884,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ shell: Object.fromEntries(mutable(result.data).map((info) => [info.id, info])), }) }, - async remove(id: string) { - await sdk.api.shell.remove({ id }) - setStore( - "location", - produce((draft) => { - for (const data of Object.values(draft)) delete data.shell?.[id] - }), - ) - }, }, location: { default() { @@ -889,6 +998,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) for (const session of response.data) registerSession(session.id) }), + sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { + const permissions = mutable(response.data).reduce>( + (result, request) => ({ + ...result, + [request.sessionID]: [...(result[request.sessionID] ?? []), request], + }), + {}, + ) + setStore("session", "permission", reconcile(permissions)) + }), + sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { + const forms = mutable(response.data).reduce>( + (result, form) => ({ + ...result, + [form.sessionID]: [...(result[form.sessionID] ?? []), form], + }), + {}, + ) + setStore("session", "form", reconcile(forms)) + }), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), @@ -911,23 +1040,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function refreshActive() { - const generation = ++connectionGeneration - const changed = new Set() - statusChanges = changed void sdk.api.session .active() .then((active) => { - if (generation !== connectionGeneration) return - const status: Record = Object.fromEntries( - Object.keys(active).map((sessionID) => [sessionID, "running" as const]), + setStore( + "session", + "status", + reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), ) - for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] - setStore("session", "status", reconcile(status)) }) .catch(() => undefined) - .finally(() => { - if (statusChanges === changed) statusChanges = undefined - }) } onCleanup( diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index efcaa8a01d..5c16acdaf8 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -470,7 +470,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } event.on("session.deleted", (evt) => { - prune(evt.data.info.id) + prune(evt.data.sessionID) }) return { diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 0ad516ceac..728ced3ed4 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -export type SDKConnectionStatus = "connected" | "connecting" +export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" type SDKEventMap = { [Type in V2Event["type"]]: Extract } const connectTimeout = 2_000 @@ -27,11 +27,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ status: SDKConnectionStatus attempt: number error?: string - connectedOnce: boolean }>({ status: "connecting", attempt: 0, - connectedOnce: false, }) let stream: AbortController | undefined @@ -70,7 +68,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ clearTimeout(timeout) attempt = 0 events.emit(first.value.type, first.value) - setConnection({ status: "connected", attempt: 0, error: undefined, connectedOnce: true }) + setConnection({ status: "connected", attempt: 0, error: undefined }) connected() while (!abort.signal.aborted && !controller.signal.aborted) { const event = await iterator.next() @@ -98,7 +96,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ } } setConnection({ - status: "connecting", + status: "reconnecting", attempt, error: error instanceof Error ? error.message : String(error), }) @@ -136,9 +134,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ error() { return connection.error }, - connectedOnce() { - return connection.connectedOnce - }, }, reload: props.reload, } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 7e691ad29c..40eac011ae 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -15,7 +15,7 @@ import type { ProviderListResponse, QuestionRequest, Session, - SnapshotFileDiff, + FileDiffInfo, Todo, VcsInfo, } from "@opencode-ai/sdk/v2" @@ -42,9 +42,6 @@ export const { provider_default: Record provider_next: ProviderListResponse console_state: ConsoleState - capabilities: { - experimentalBackgroundSubagents: boolean - } provider_auth: Record agent: Agent[] command: Command[] @@ -52,7 +49,7 @@ export const { question: Record config: Config session: Session[] - session_diff: Record + session_diff: Record todo: Record message: Record part: Record @@ -71,9 +68,6 @@ export const { connected: [], }, console_state: emptyConsoleState, - capabilities: { - experimentalBackgroundSubagents: false, - }, provider_auth: {}, agent: [], command: [], diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679..ae41cec303 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,8 @@ -import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo } from "solid-js" +import { useData } from "../../context/data" +import { lastAssistantWithUsage } from "../../util/session" const id = "internal:sidebar-context" @@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", { }) function View(props: { api: TuiPluginApi; session_id: string }) { + const data = useData() const theme = () => props.api.theme.current - const msg = createMemo(() => props.api.state.session.messages(props.session_id)) - const session = createMemo(() => props.api.state.session.get(props.session_id)) + 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 state = createMemo(() => { - const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID) if (!last) { return { tokens: 0, @@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + 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, diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c59046a017..6fb51ffafc 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime" const id = "internal:sidebar-footer" -function View(props: { api: TuiPluginApi; sessionID: string }) { +function View(props: { api: TuiPluginApi; directory: string }) { const paths = useTuiPaths() const theme = () => props.api.theme.current const has = createMemo(() => @@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const session = props.api.state.session.get(props.sessionID) - const dir = session?.directory || props.api.state.path.directory || paths.cwd - const out = abbreviateHome(dir, paths.home) - const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined + const out = abbreviateHome(props.directory, paths.home) + 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 { @@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { sidebar_footer(_ctx, props) { - return + return }, }, }) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index ed88a1107f..1bee45dedc 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 { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { TextAttributes, type BorderSides, @@ -56,7 +56,7 @@ type DiffFile = { readonly status: "added" | "deleted" | "modified" } -const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] => +const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] => diffs.flatMap((item) => item.file ? [ diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index a5033b62e0..b8f2727bae 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) { } const tui: TuiPlugin = async (api) => { - const active = new Set() const errored = new Set() + const terminal = new Set() const forms = new Set() const questions = new Set() const permissions = new Set() @@ -73,14 +73,13 @@ const tui: TuiPlugin = async (api) => { }) const started = (sessionID: string) => { - active.add(sessionID) errored.delete(sessionID) + terminal.delete(sessionID) } const ended = (sessionID: string) => { - if (!active.has(sessionID)) return - active.delete(sessionID) - + if (terminal.has(sessionID)) return + terminal.add(sessionID) if (errored.has(sessionID)) { errored.delete(sessionID) return @@ -90,28 +89,25 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID)) - api.event.on("session.shell.started", (event) => started(event.data.sessionID)) - api.event.on("session.step.started", (event) => started(event.data.sessionID)) - api.event.on("session.retried", (event) => started(event.data.sessionID)) - api.event.on("session.compaction.started", (event) => started(event.data.sessionID)) - api.event.on("session.shell.ended", (event) => ended(event.data.sessionID)) - api.event.on("session.step.ended", (event) => { - if (event.data.finish === "tool-calls") return - ended(event.data.sessionID) - }) - api.event.on("session.step.failed", (event) => { + api.event.on("session.execution.started", (event) => started(event.data.sessionID)) + api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID)) + api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID)) + api.event.on("session.execution.failed", (event) => { const sessionID = event.data.sessionID - if (!active.has(sessionID)) return + if (errored.has(sessionID)) { + ended(sessionID) + return + } errored.add(sessionID) - notify(api, sessionID, "Session error", "error") + notify(api, sessionID, event.data.error.message, "error") ended(sessionID) }) api.event.on("session.error", (event) => { const sessionID = event.data.sessionID if (!sessionID) return - if (!active.has(sessionID)) return + if (api.state.session.status(sessionID)?.type !== "busy") return + if (errored.has(sessionID)) return errored.add(sessionID) notify(api, sessionID, sessionErrorMessage(event.data.error), "error") }) diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4074da3a5..1d5ba75c97 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -12,6 +12,8 @@ import { useEditorContext } from "../context/editor" import { useTerminalDimensions } from "@opentui/solid" import { useTuiConfig } from "../config" import { HomeSessionDestinationProvider } from "./home/session-destination" +import { useData } from "../context/data" +import { LocationProvider } from "../context/location" let once = false const placeholder = { @@ -30,6 +32,7 @@ export function Home() { const editor = useEditorContext() const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() + const data = useData() const promptMaxWidth = createMemo(() => { const configured = tuiConfig.prompt?.max_width if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) @@ -68,28 +71,30 @@ export function Home() { }) return ( - - - - - - - - + + + + + + + + + + + + + + } placeholders={placeholder} /> + + + + + - - - - } placeholders={placeholder} /> - + + - - - - - - - - + + ) } diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx index 352010840b..35611b00b7 100644 --- a/packages/tui/src/routes/home/session-destination.tsx +++ b/packages/tui/src/routes/home/session-destination.tsx @@ -10,7 +10,7 @@ import { import { useSync } from "../../context/sync" import { useTuiPaths } from "../../context/runtime" -export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } type Context = { destination: Accessor diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index 1ea9f73fd3..90e90a1785 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -2,12 +2,16 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j import { createStore } from "solid-js/store" import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core" import { useData } from "../../../context/data" +import { useLocation } from "../../../context/location" +import { useSDK } from "../../../context/sdk" import { useTheme, selectedForeground } from "../../../context/theme" import { useBindings, useCommandShortcut } from "../../../keymap" import { useComposerTab } from "./index" export function ShellTab(props: { sessionID: string }) { const data = useData() + const location = useLocation() + const sdk = useSDK() const { theme } = useTheme() const fg = selectedForeground(theme) const composer = useComposerTab() @@ -79,7 +83,11 @@ export function ShellTab(props: { sessionID: string }) { run() { const entry = selectedEntry() if (!entry) return - void data.shell.remove(entry.id) + const ref = location() + void sdk.api.shell.remove({ + id: entry.id, + location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined, + }) }, }, ], diff --git a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx deleted file mode 100644 index d0952a907e..0000000000 --- a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { createMemo, onMount } from "solid-js" -import { useSync } from "../../context/sync" -import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" -import type { TextPart } from "@opencode-ai/sdk/v2" -import { Locale } from "../../util/locale" -import { useSDK } from "../../context/sdk" -import { useRoute } from "../../context/route" -import { useDialog, type DialogContext } from "../../ui/dialog" -import { emptyPrompt, type PromptInfo } from "../../component/prompt/history" - -export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) { - const sync = useSync() - const dialog = useDialog() - const sdk = useSDK() - const route = useRoute() - - onMount(() => { - dialog.setSize("large") - }) - - const options = createMemo((): DialogSelectOption[] => { - const messages = sync.data.message[props.sessionID] ?? [] - const fullSession = { - title: "Full session", - value: undefined, - onSelect: async (dialog: DialogContext) => { - const forked = await sdk.client.session.fork({ sessionID: props.sessionID }) - route.navigate({ - sessionID: forked.data!.id, - type: "session", - }) - dialog.clear() - }, - } satisfies DialogSelectOption - 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 - result.push({ - title: part.text.replace(/\n/g, " "), - value: message.id, - footer: Locale.time(message.time.created), - onSelect: async (dialog) => { - const forked = await sdk.client.session.fork({ - sessionID: props.sessionID, - messageID: message.id, - }) - const parts = sync.data.part[message.id] ?? [] - const prompt = parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.text += part.text - } - if (part.type === "file") { - const files = (agg.files ??= []) - files.push({ - uri: part.url, - name: part.filename, - mention: part.source?.text - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }) - } - return agg - }, - emptyPrompt() as PromptInfo, - ) - route.navigate({ - sessionID: forked.data!.id, - type: "session", - prompt, - }) - dialog.clear() - }, - }) - } - return [fullSession, ...result.reverse()] - }) - - return props.onMove(option.value)} title="Fork session" options={options()} /> -} diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx new file mode 100644 index 0000000000..c6eb91594d --- /dev/null +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -0,0 +1,89 @@ +import { createMemo, createSignal, onMount, Show } from "solid-js" +import { useData } from "../../context/data" +import { useRoute } from "../../context/route" +import { useSDK } from "../../context/sdk" +import { Spinner } from "../../component/spinner" +import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" +import { useDialog } from "../../ui/dialog" +import { useToast } from "../../ui/toast" +import { errorMessage } from "../../util/error" +import { Locale } from "../../util/locale" + +export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const route = useRoute() + const toast = useToast() + const [pending, setPending] = createSignal(false) + + const fork = async (messageID?: string) => { + setPending(true) + const result = await sdk.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + return undefined + }) + if (!result) return dialog.clear() + const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined + route.navigate({ + sessionID: result.id, + type: "session", + prompt: + message?.type === "user" + ? { + text: message.text, + files: message.files?.map((file) => ({ + uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + name: file.name, + description: file.description, + mention: file.mention, + })), + agents: structuredClone(message.agents ?? []), + pasted: [], + } + : undefined, + }) + dialog.clear() + toast.show({ message: "Forked session", variant: "success", duration: 4000 }) + } + + onMount(() => { + dialog.setSize("large") + if (props.messageID) void fork(props.messageID) + }) + + const options = createMemo((): DialogSelectOption[] => [ + { + title: "Full session", + value: undefined, + onSelect: () => fork(), + }, + ...data.session.message + .list(props.sessionID) + .filter((message) => message.type === "user") + .toReversed() + .map((message) => ({ + title: message.text.replace(/\n/g, " "), + value: message.id, + footer: Locale.time(message.time.created), + onSelect: () => fork(message.id), + })), + ]) + + return ( + + Forking session... + + } + > + props.onMove?.(option.value)} + title="Fork session" + options={options()} + /> + + ) +} diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 240be47408..db64c7b5da 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -5,8 +5,9 @@ import { useClipboard } from "../../context/clipboard" import { useToast } from "../../ui/toast" import { useSDK } from "../../context/sdk" import { errorMessage } from "../../util/error" +import { DialogFork } from "./dialog-fork" -export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) { +export function DialogMessage(props: { messageID: string; sessionID: string }) { const data = useData() const clipboard = useClipboard() const toast = useToast() @@ -23,7 +24,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set description: "undo messages and file changes", onSelect: async (dialog) => { await sdk.api.session - .revertStage({ sessionID: props.sessionID, messageID: props.messageID }) + .revert.stage({ sessionID: props.sessionID, messageID: props.messageID }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, @@ -55,8 +56,9 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set value: "session.fork", description: "create a new session", onSelect: (dialog) => { - toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) - dialog.clear() + const value = message() + if (!value || value.type !== "user") return + dialog.replace(() => ) }, }, ]} diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index bda87119d4..b3e162e9ad 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-timeline.tsx @@ -5,12 +5,10 @@ import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" -import type { PromptInfo } from "../../component/prompt/history" export function DialogTimeline(props: { sessionID: string onMove: (messageID: string) => void - setPrompt?: (prompt: PromptInfo) => void }) { const sync = useSync() const dialog = useDialog() @@ -33,9 +31,7 @@ export function DialogTimeline(props: { value: message.id, footer: Locale.time(message.time.created), onSelect: (dialog) => { - dialog.replace(() => ( - - )) + dialog.replace(() => ) }, }) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 43103c3d84..1dc2baa2ba 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -21,19 +21,19 @@ import { useProject } from "../../context/project" import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" -import { Spinner } from "../../component/spinner" -import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" +import { Spinner, SPINNER_FRAMES } from "../../component/spinner" +import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { - ModelV2Info, - SessionMessage, + ModelInfo, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, SessionMessageUser, - SessionV2Info, + SessionInfo, } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" @@ -46,6 +46,7 @@ import { useDialog } from "../../ui/dialog" import { DialogSessionRename } from "../../component/dialog-session-rename" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" +import { DialogFork } from "./dialog-fork" import { Sidebar } from "./sidebar" import { Composer } from "./composer" import { filetype } from "../../util/filetype" @@ -70,7 +71,7 @@ import { usePluginRuntime } from "../../plugin/runtime" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" -import { createSessionRows, type PartRef, type SessionRow } from "./rows" +import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" import { switchLabel } from "../../util/model" addDefaultParsers(parsers.parsers) @@ -129,7 +130,7 @@ const context = createContext<{ showGenericToolOutput: () => boolean groupExploration: () => boolean diffWrapMode: () => "word" | "none" - models: () => ModelV2Info[] + models: () => ModelInfo[] tui: ReturnType }>() @@ -370,7 +371,18 @@ export function Session() { value: "session.fork", category: "Session", slash: { name: "fork" }, - run: () => unavailable("Forking"), + run: () => { + dialog.replace(() => ( + { + if (!messageID) return + const child = scroll.getChildren().find((child) => child.id === messageID) + if (child) scroll.scrollBy(child.y - scroll.y - 1) + }} + /> + )) + }, }, { title: "Compact session", @@ -415,7 +427,7 @@ export function Session() { dialog.clear() return } - const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then( + const error = await sdk.api.session.revert.stage({ sessionID: route.sessionID, messageID: target }).then( () => undefined, (error) => error, ) @@ -432,7 +444,7 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -999,7 +1011,7 @@ export function Session() { ) } -function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) { +function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) { return ( @@ -1037,7 +1049,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = ) } -function BackgroundToolHint(props: { messages: SessionMessage[] }) { +function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const { theme } = useTheme() const shortcut = useCommandShortcut("session.background") const visible = createMemo(() => { @@ -1065,14 +1077,14 @@ function BackgroundToolHint(props: { messages: SessionMessage[] }) { ) } -function SessionMessageView(props: { message: SessionMessage }) { +function SessionMessageView(props: { message: SessionMessageInfo }) { return ( - } /> + } /> @@ -1081,22 +1093,22 @@ function SessionMessageView(props: { message: SessionMessage }) { when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"} > }> - } /> + } /> - + } /> ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) { +function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { const message = createMemo(() => props.message(props.partRef.messageID)) const part = createMemo(() => { const item = message() if (item?.type !== "assistant") return - return item.content.find((part) => part.id === props.partRef.partID) + return resolvePart(item, props.partRef.partID) }) return ( @@ -1125,7 +1137,7 @@ function SessionGroupView(props: { refs: PartRef[] pending: PartRef[] completed: boolean - message: (messageID: string) => SessionMessage | undefined + message: (messageID: string) => SessionMessageInfo | undefined }) { const { theme } = useTheme() const ctx = use() @@ -1136,7 +1148,7 @@ function SessionGroupView(props: { refs.flatMap((ref) => { const message = props.message(ref.messageID) if (message?.type !== "assistant") return [] - const part = message.content.find((part) => part.id === ref.partID) + const part = resolvePart(message, ref.partID) if (part?.type !== "tool") return [] return [part] }) @@ -1216,6 +1228,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { {errorMessage(props.message.error)} + @@ -1234,7 +1247,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { ) } -function SessionSwitchMessageV2(props: { message: SessionMessage }) { +function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) { const ctx = use() const { theme } = useTheme() const text = () => { @@ -1243,10 +1256,14 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { return switchLabel(props.message.model, ctx.models(), props.message.previous) return "" } - return {text()} + return ( + + {text()} + + ) } -function SessionNoticeMessageV2(props: { message: SessionMessage }) { +function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { const { theme } = useTheme() const text = () => { if (props.message.type === "system") return "Instructions updated" @@ -1260,7 +1277,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { ) } -function SessionSkillMessage(props: { message: Extract }) { +function SessionSkillMessage(props: { message: Extract }) { const { theme } = useTheme() return ( @@ -1269,9 +1286,57 @@ function SessionSkillMessage(props: { message: Extract +function CompactionMessage(props: { + message?: Extract + status?: "running" + text?: string +}) { + 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 + return ( + + + + + + + }> + + + + + + + + + + + Compaction + + + + + + + + + + ) } function statusLabel(status: "added" | "modified" | "deleted") { @@ -1283,7 +1348,7 @@ function statusLabel(status: "added" | "modified" | "deleted") { function RevertMessage(props: { count: number files: ReadonlyArray<{ - readonly path: string + readonly file: string readonly status: "added" | "modified" | "deleted" readonly additions: number readonly deletions: number @@ -1303,7 +1368,7 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -1332,7 +1397,7 @@ function RevertMessage(props: { {statusLabel(file.status)} - {Locale.truncateLeft(file.path, 60)} + {Locale.truncateLeft(file.file, 60)} 0}> +{file.additions} @@ -1353,7 +1418,7 @@ function RevertMessage(props: { ) } -function ShellMessage(props: { message: Extract }) { +function ShellMessage(props: { message: Extract }) { const { theme } = useTheme() const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? "")) @@ -1368,7 +1433,7 @@ function ShellMessage(props: { message: Extract - $ {props.message.shell.command} + $ {props.message.command} {output()} @@ -1384,9 +1449,9 @@ function UserMessage(props: { message: SessionMessageUser }) { const { theme } = useTheme() const [hover, setHover] = createSignal(false) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) - const queued = createMemo(() => props.message.metadata?.queued === true) - const queuedFg = createMemo(() => selectedForeground(theme, color())) - const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) + const queued = createMemo( + () => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id), + ) const dialog = useDialog() const renderer = useRenderer() @@ -1395,7 +1460,7 @@ function UserMessage(props: { message: SessionMessageUser }) { {props.message.text} - + {(file) => { - const label = file.mime === "application/x-directory" ? "Directory" : file.mime + const label = file.mime === "application/x-directory" ? "dir" : "file" return ( - - {` ${label} `} - + {` ${label} `} {" "} {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} @@ -1436,18 +1505,9 @@ function UserMessage(props: { message: SessionMessageUser }) { - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - } - > + - QUEUED + {Locale.todayTimeOrDateTime(props.message.time.created)} @@ -1485,7 +1545,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole .map((part) => part.type === "tool" && ["read", "glob", "grep"].includes(toolDisplay(part.name)) && - part.state.status !== "pending" + part.state.status !== "streaming" ? part : undefined, ) @@ -1552,6 +1612,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole {errorMessage(props.message.error)} + @@ -1571,6 +1632,21 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole ) } +function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { + const { theme } = useTheme() + return ( + + {(retry) => ( + + + Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}] + + + )} + + ) +} + function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) { const { theme } = useTheme() const pathFormatter = usePathFormatter() @@ -1741,7 +1817,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { const data = useData() const display = createMemo(() => toolDisplay(props.part.name)) const activeBackgroundWork = createMemo(() => { - if (props.part.state.status === "pending") return false + 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)) @@ -1765,13 +1841,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { const toolprops = { get metadata() { - return props.part.state.status === "pending" ? {} : props.part.state.structured + return props.part.state.status === "streaming" ? {} : props.part.state.structured }, get input() { return typeof props.part.state.input === "string" ? {} : props.part.state.input }, get output() { - if (props.part.state.status === "pending") return undefined + if (props.part.state.status === "streaming") return undefined return props.part.state.content .flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri])) .join("\n") @@ -1817,7 +1893,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - + @@ -2096,7 +2172,7 @@ function Shell(props: ToolProps) { const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning()) const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => { - if (props.part.state.status === "pending") return "" + if (props.part.state.status === "streaming") return "" if (shellID()) return "" const content = props.part.state.content[0] return stripAnsi(content?.type === "text" ? content.text.trim() : "") @@ -2118,7 +2194,7 @@ function Shell(props: ToolProps) { Writing command... ) : ( Writing command... @@ -2142,7 +2218,7 @@ function Shell(props: ToolProps) { - Backgrounded + Background @@ -2287,7 +2363,7 @@ function Subagent(props: ToolProps) { {formatSubagentTitle( Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"), description() ?? "Subagent", - props.metadata.background === true, + props.input.background === true || props.metadata.status === "running", )} ) @@ -2327,7 +2403,7 @@ function executeCalls(value: unknown): ExecuteCall[] { function Execute(props: ToolProps) { const ctx = use() const { theme } = useTheme() - const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running") + const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running") const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) const hasRuntimeError = createMemo(() => props.metadata.error === true) @@ -2423,7 +2499,7 @@ function Edit(props: ToolProps) { : "# Preparing edit..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2515,7 +2591,7 @@ function ApplyPatch(props: ToolProps) { : "# Preparing patch..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2585,9 +2661,10 @@ function Question(props: ToolProps) { } function Skill(props: ToolProps) { + const name = createMemo(() => stringValue(props.metadata.name) ?? stringValue(props.input.id)) return ( - - Skill "{stringValue(props.input.name)}" + + Skill "{name()}" ) } @@ -2646,7 +2723,7 @@ const toolDisplays = new Set([ "edit", "subagent", "execute", - "apply_patch", + "patch", "todowrite", "question", "skill", @@ -2655,7 +2732,7 @@ const toolDisplays = new Set([ export function toolDisplay(tool: string) { // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render // them with the renamed views. - const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool + const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool return toolDisplays.has(normalized) ? normalized : "generic" } @@ -2665,15 +2742,15 @@ function recordValue(value: unknown): Record | undefined { } function formatSessionTranscript( - session: SessionV2Info, - messages: SessionMessage[], + session: SessionInfo, + messages: SessionMessageInfo[], thinking: boolean, toolDetails: boolean, ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") - return [`## Shell\n\n\`\`\`\n$ ${message.shell.command}\n${message.output?.output ?? ""}\n\`\`\``] + return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] const content = message.content.flatMap((item) => { if (item.type === "text") return [item.text] @@ -2683,7 +2760,7 @@ function formatSessionTranscript( const output = item.state.status === "error" ? item.state.error.message - : item.state.status === "pending" + : item.state.status === "streaming" ? "" : item.state.content .flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri])) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a137d6272a..a0ca4775c3 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -148,7 +148,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const message = data.session.message.get(props.request.sessionID, tool.messageID) if (message?.type !== "assistant") return {} const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) - if (part?.type === "tool" && part.state.status !== "pending") return part.state.input + if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input return {} }) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index c0d44c3d80..c8764f3030 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,4 +1,4 @@ -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useData } from "../../context/data" @@ -27,8 +27,9 @@ export function createSessionRows(sessionID: Accessor) { function reduce() { const messages = data.session.message.list(sessionID()) + const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages) + const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) partitionPending(rows, pendingPermissions()) return rows } @@ -73,13 +74,25 @@ export function createSessionRows(sessionID: Accessor) { createEffect( on( () => - data.session.message - .list(sessionID()) - .flatMap((message) => - message.type === "user" - ? [{ id: message.id, created: message.time.created, queued: message.metadata?.queued === true }] + data.session.message.list(sessionID()).flatMap((message) => + message.type === "user" + ? [ + { + id: message.id, + created: message.time.created, + input: data.session.input.has(sessionID(), message.id), + }, + ] + : message.type === "compaction" + ? [ + { + id: message.id, + created: message.time.created, + input: message.status === "running", + }, + ] : [], - ), + ), () => setRows(reconcile(reduce())), ), ) @@ -88,9 +101,11 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return - const queued = isQueued(messageID) - const index = queued ? draft.length : queuedStart(draft) - if (!queued) completePrevious(draft, index) + const pending = isPending(messageID) + const message = data.session.message.get(sessionID(), messageID) + const index = + message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft) + if (!pending) completePrevious(draft, index) draft.splice(index, 0, { type: "message", messageID }) }), ) @@ -131,13 +146,22 @@ export function createSessionRows(sessionID: Accessor) { }), ) - const isQueued = (messageID: string) => { + const removeFooter = (messageID: string) => + setRows( + produce((draft) => { + const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID) + if (index !== -1) draft.splice(index, 1) + }), + ) + + const isPending = (messageID: string) => { const message = data.session.message.get(sessionID(), messageID) - return message?.type === "user" && message.metadata?.queued === true + if (message?.type === "user") return data.session.input.has(sessionID(), messageID) + return message?.type === "compaction" && message.status === "running" } const queuedStart = (rows: SessionRow[]) => { - const index = rows.findIndex((row) => row.type === "message" && isQueued(row.messageID)) + const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID)) return index === -1 ? rows.length : index } @@ -149,6 +173,7 @@ export function createSessionRows(sessionID: Accessor) { } const subscriptions = [ data.on("session.prompt.admitted", input), + data.on("session.compaction.started", message), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -157,27 +182,35 @@ 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", 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: event.data.textID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) }), data.on("session.text.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) }), data.on("session.reasoning.delta", (event) => { if (event.data.sessionID === sessionID()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) }), data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) }), data.on("session.tool.input.started", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) }), + data.on("session.retry.scheduled", (event) => { + if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) + }), + data.on("session.step.started", (event) => { + if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID) + }), data.on("session.step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) @@ -191,21 +224,28 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessage[]) { - return [...messages.filter((message) => !isQueuedMessage(message)), ...messages.filter(isQueuedMessage)].reduce< - SessionRow[] - >((rows, message) => { +export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { + const isInput = (message: SessionMessageInfo) => inputs.has(message.id) + const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") + const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) + return [ + ...messages.filter((message) => !pending.has(message.id)), + ...pendingCompactions, + ...messages.filter(isInput), + ].reduce((rows, message) => { if (message.type !== "assistant") { if (message.type === "synthetic" && !message.description?.trim()) return rows - if (!isQueuedMessage(message)) completePrevious(rows) + if (!pending.has(message.id)) completePrevious(rows) rows.push({ type: "message", messageID: message.id }) return rows } + const ordinals = { text: 0, reasoning: 0 } message.content.forEach((part) => { + const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return - append(rows, { messageID: message.id, partID: part.id }, part) + append(rows, { messageID: message.id, partID }, part) }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) { + if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) { completePrevious(rows) rows.push({ type: "assistant-footer", messageID: message.id }) } @@ -213,8 +253,13 @@ export function reduceSessionRows(messages: SessionMessage[]) { }, []) } -function isQueuedMessage(message: SessionMessage) { - return message.type === "user" && message.metadata?.queued === true +export function resolvePart(message: SessionMessageAssistant, partID: string) { + const tool = message.content.find((part) => part.type === "tool" && part.id === partID) + if (tool) return tool + const match = /^(text|reasoning):(\d+)$/.exec(partID) + if (!match) return + const ordinal = Number(match[2]) + return message.content.filter((part) => part.type === match[1])[ordinal] } function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 5639f6451f..bd415768a2 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - + Open diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 71b3a161e5..f02ef3f20e 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -6,6 +6,7 @@ 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" export function SubagentFooter() { const route = useRouteData("session") @@ -22,17 +23,15 @@ 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 = - current.tokens.input + - current.tokens.output + - current.tokens.reasoning + - current.tokens.cache.read + - current.tokens.cache.write + 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 === current.model?.providerID && model.id === current.model.id) + ?.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 @@ -83,10 +82,10 @@ export function SubagentFooter() { setHover("parent")} - onMouseOut={() => setHover(null)} + onMouseOver={() => setHover("parent")} + onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatchCommand("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > Parent {parentShortcut()} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d0..41f583b1c6 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,14 @@ +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" + 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) } + +export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { + const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 + if (boundary && boundaryIndex === -1) return undefined + return messages.findLast( + (message, index): message is SessionMessageAssistant & { tokens: NonNullable } => + message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex), + ) +} diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index 18e256bd34..c9d92434e2 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -6,7 +6,7 @@ export function webSearchProviderLabel(provider: unknown) { export function toolDisplayMetadata(state: unknown): Record { if (!state || typeof state !== "object" || Array.isArray(state)) return {} - if (!("status" in state) || state.status === "pending") return {} + if (!("status" in state) || state.status === "streaming") return {} if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {} if (Array.isArray(state.structured)) return {} return state.structured as Record diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 30601bb528..c4302e2790 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -58,10 +58,24 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { } }) -test("app.exit prints the session epilogue after scoped cleanup", async () => { +test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + let initialTitle!: () => void + const initialTitleSet = new Promise((resolve) => { + initialTitle = resolve + }) + let renamedTitle!: () => void + const renamedTitleSet = new Promise((resolve) => { + renamedTitle = resolve + }) + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OC | Demo session") initialTitle() + if (title === "OC | Renamed session") renamedTitle() + setTitle(title) + } const events = createEventStream() const calls = createFetch((url) => { if (url.pathname === "/api/session") @@ -100,7 +114,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { client: createClient(calls.fetch), api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), - args: { continue: true }, + args: { sessionID: "dummy" }, pluginHost: { async start(input) { api = input.api @@ -112,12 +126,19 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { ) await ready - await setup.renderOnce() - await setup.renderOnce() + await initialTitleSet + events.emit({ + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: { aggregateID: "dummy", seq: 1, version: 1 }, + data: { sessionID: "dummy", title: "Renamed session" }, + }) + await renamedTitleSet api?.keymap.dispatchCommand("app.exit") await task - expect(stdout).toContain("Demo session") + expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode -s dummy") } finally { process.stdout.write = originalWrite diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 0014292d23..ac3abc9581 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -49,6 +49,7 @@ async function setup() { state: { session: { get: (sessionID: string) => sessions[sessionID], + status: () => ({ type: "busy" }), }, }, }), @@ -92,50 +93,38 @@ function permission(id: string, sessionID = "session"): PermissionRequest { } } -function durable(sessionID: string) { +function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } { return { aggregateID: sessionID, seq: 0, version: 1 } } -function stepStarted(id: string, sessionID = "session"): V2Event { +function executionStarted(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "session.step.started", + type: "session.execution.started", durable: durable(sessionID), - data: { - sessionID, - assistantMessageID: `msg_${id}`, - agent: "build", - model: { id: "model", providerID: "provider" }, - }, + data: { sessionID }, } } -function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event { +function executionSucceeded(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "session.step.ended", + type: "session.execution.succeeded", durable: durable(sessionID), - data: { - sessionID, - assistantMessageID: `msg_${id}`, - finish, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }, + data: { sessionID }, } } -function stepFailed(id: string, sessionID = "session"): V2Event { +function executionFailed(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "session.step.failed", + type: "session.execution.failed", durable: durable(sessionID), data: { sessionID, - assistantMessageID: `msg_${id}`, error: { type: "unknown", message: "boom" }, }, } @@ -224,12 +213,12 @@ describe("internal notifications TUI plugin", () => { ]) }) - test("notifies when an active session becomes idle and suppresses no-op idle", async () => { + test("notifies for terminal lifecycle events even when attached after execution started", async () => { const harness = await setup() - harness.emit(stepEnded("event-1")) - harness.emit(stepStarted("event-2")) - harness.emit(stepEnded("event-3")) + harness.emit(executionSucceeded("event-1")) + harness.emit(executionStarted("event-2")) + harness.emit(executionSucceeded("event-3")) expect(harness.notifications).toEqual([ { @@ -238,6 +227,12 @@ describe("internal notifications TUI plugin", () => { notification: { when: "blurred" }, sound: { name: "done", when: "always" }, }, + { + title: "Demo session", + message: "Session done", + notification: { when: "blurred" }, + sound: { name: "done", when: "always" }, + }, ]) }) @@ -250,8 +245,8 @@ describe("internal notifications TUI plugin", () => { type: "form.created", data: { form: form("form-1", "subagent") }, }) - harness.emit(stepStarted("event-2", "subagent")) - harness.emit(stepEnded("event-3", "subagent")) + harness.emit(executionStarted("event-2", "subagent")) + harness.emit(executionSucceeded("event-3", "subagent")) expect(harness.notifications).toEqual([ { @@ -272,14 +267,14 @@ describe("internal notifications TUI plugin", () => { test("notifies session errors once and suppresses the following idle done notification", async () => { const harness = await setup() - harness.emit(stepStarted("event-1")) - harness.emit(stepFailed("event-2")) - harness.emit(stepEnded("event-3")) + harness.emit(executionStarted("event-1")) + harness.emit(executionFailed("event-2")) + harness.emit(executionSucceeded("event-3")) expect(harness.notifications).toEqual([ { title: "Demo session", - message: "Session error", + message: "boom", notification: { when: "blurred" }, sound: { name: "error", when: "always" }, }, @@ -289,20 +284,21 @@ describe("internal notifications TUI plugin", () => { test("special-cases aborts and model response timeouts", async () => { const harness = await setup() - harness.emit(stepStarted("event-1", "abort")) + harness.emit(executionStarted("event-1", "abort")) harness.emit({ id: "event-2", created: 0, type: "session.error", data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, }) - harness.emit(stepStarted("event-3", "timeout")) + harness.emit(executionStarted("event-3", "timeout")) harness.emit({ id: "event-4", created: 0, type: "session.error", data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, }) + harness.emit(executionFailed("event-5", "timeout")) expect(harness.notifications).toEqual([ { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b9beb0e536..e0612bb059 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -6,9 +6,9 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" -import { SDKProvider } from "../../../src/context/sdk" +import { SDKProvider, useSDK } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" -import { createSessionRows } from "../../../src/routes/session/rows" +import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -24,6 +24,12 @@ function emitEvent(events: ReturnType, event: V2Event) events.emit({ ...event, location: { directory } }) } +function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } +function durable( + sessionID: string, + seq: number, + version: Version, +): { aggregateID: string; seq: number; version: Version } function durable(sessionID: string, seq = 0, version = 1) { return { aggregateID: sessionID, seq, version } } @@ -108,6 +114,348 @@ test("refreshes resources into reactive getters", async () => { } }) +test("applies absolute usage events to session info", async () => { + const events = createEventStream() + const sessionID = "ses_usage_refresh" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}`) + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Usage", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.refresh(sessionID) + emitEvent(events, { + id: "evt_usage_2", + created: 2, + type: "session.usage.updated", + data: { + sessionID, + cost: 0.5, + tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.5) + expect(data.session.get(sessionID)?.tokens).toEqual({ + input: 5, + output: 2, + reasoning: 1, + cache: { read: 1, write: 1 }, + }) + + emitEvent(events, { + id: "evt_usage_3", + created: 3, + type: "session.usage.updated", + data: { + sessionID, + cost: 1, + tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 1) + expect(data.session.get(sessionID)?.title).toBe("Usage") + + emitEvent(events, { + id: "evt_usage_deleted", + created: 9, + type: "session.deleted", + durable: durable(sessionID, 9, 2), + data: { sessionID }, + }) + await wait(() => data.session.get(sessionID) === undefined) + } finally { + app.renderer.destroy() + } +}) + +test("truncates committed revert messages without changing lifetime usage", async () => { + const events = createEventStream() + const sessionID = "ses_revert_usage" + let cost = 0 + let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname !== `/api/session/${sessionID}`) return + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost, + tokens, + time: { created: 0, updated: 0 }, + title: "Revert usage", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.refresh(sessionID) + emitEvent(events, { + id: "evt_revert_boundary_started", + created: 1, + type: "session.step.started", + durable: durable(sessionID, 1), + data: { + sessionID, + assistantMessageID: "msg_revert_boundary", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + }) + cost = 0.5 + tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } } + emitEvent(events, { + id: "evt_revert_boundary_ended", + created: 2, + type: "session.step.ended", + durable: durable(sessionID, 2), + data: { + sessionID, + assistantMessageID: "msg_revert_boundary", + finish: "stop", + cost: 0.5, + tokens, + }, + }) + emitEvent(events, { + id: "evt_revert_boundary_usage", + created: 2, + type: "session.usage.updated", + data: { sessionID, cost, tokens }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.5) + + emitEvent(events, { + id: "evt_revert_later_started", + created: 3, + type: "session.step.started", + durable: durable(sessionID, 3), + data: { + sessionID, + assistantMessageID: "msg_revert_later", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + }) + cost = 0.75 + tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } } + emitEvent(events, { + id: "evt_revert_later_ended", + created: 4, + type: "session.step.ended", + durable: durable(sessionID, 4), + data: { + sessionID, + assistantMessageID: "msg_revert_later", + finish: "stop", + cost: 0.25, + tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }) + emitEvent(events, { + id: "evt_revert_later_usage", + created: 4, + type: "session.usage.updated", + data: { sessionID, cost, tokens }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.75) + emitEvent(events, { + id: "evt_revert_staged", + created: 5, + type: "session.revert.staged", + durable: durable(sessionID, 5), + data: { sessionID, revert: { messageID: "msg_revert_later" } }, + }) + await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_later") + + emitEvent(events, { + id: "evt_revert_committed", + created: 6, + type: "session.revert.committed", + durable: durable(sessionID, 6), + data: { sessionID, to: "msg_revert_later" }, + }) + await wait(() => data.session.message.ids(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.get(sessionID)?.revert).toBeUndefined() + expect(data.session.get(sessionID)?.tokens).toEqual(tokens) + } finally { + app.renderer.destroy() + } +}) + +test("updates session location when moved", async () => { + const events = createEventStream() + const destination = "/tmp/opencode-moved" + const calls = createFetch((url) => { + if (url.pathname === "/api/session/ses_test") + return json({ + data: { + id: "ses_test", + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Test session", + location: { directory }, + }, + }) + }, 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 data.session.refresh("ses_test") + emitEvent(events, { + id: "evt_moved_1", + created: 1, + type: "session.moved", + durable: durable("ses_test"), + data: { + sessionID: "ses_test", + location: { directory: destination }, + subpath: "packages/cli", + }, + }) + await wait(() => data.session.get("ses_test")?.location.directory === destination) + expect(data.session.get("ses_test")?.subpath).toBe("packages/cli") + } finally { + app.renderer.destroy() + } +}) + +test("restores running manual compaction before applying live deltas", async () => { + const events = createEventStream() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-compaction/message") + return json({ + data: [ + { + id: "message-compaction", + type: "compaction", + status: "running", + reason: "manual", + summary: "Existing ", + recent: "", + time: { created: 1 }, + }, + ], + cursor: {}, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.message.refresh("session-compaction") + expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({ + type: "compaction", + status: "running", + summary: "Existing ", + }) + + emitEvent(events, { + id: "evt_compaction_delta", + created: 2, + type: "session.compaction.delta", + data: { sessionID: "session-compaction", text: "summary" }, + }) + + await wait(() => { + const message = data.session.message.get("session-compaction", "message-compaction") + return message?.type === "compaction" && message.status === "running" && message.summary === "Existing summary" + }) + } finally { + app.renderer.destroy() + } +}) + test("reconnects the event stream and bootstraps fresh data", async () => { const events = createEventStream() const requests = { active: 0, event: 0, model: 0 } @@ -146,9 +494,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => { }) }, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -167,37 +517,24 @@ test("reconnects the event stream and bootstraps fresh data", async () => { try { await wait(() => data.location.model.list()?.[0]?.id === "model-1") await wait(() => data.session.status("session-stale") === "running") - expect(data.connection.status()).toBe("connected") - expect(data.connection.attempt()).toBe(0) + expect(sdk.connection.status()).toBe("connected") + expect(sdk.connection.attempt()).toBe(0) events.disconnect() - await wait(() => data.connection.status() === "connecting") - expect(data.connection.attempt()).toBe(1) - expect(data.connection.error()).toBe("Event stream disconnected") + await wait(() => sdk.connection.status() === "reconnecting") + expect(sdk.connection.attempt()).toBe(1) + expect(sdk.connection.error()).toBe("Event stream disconnected") - await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000) - emitEvent(events, { - id: "evt_step_started_after_reconnect", - created: 1, - type: "session.step.started", - durable: durable("session-new"), - data: { - sessionID: "session-new", - assistantMessageID: "message-new", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - await wait(() => data.session.status("session-new") === "running") - resolveActive(json({ data: {} })) + await wait(() => requests.active === 2 && sdk.connection.status() === "connected", 4000) + resolveActive(json({ data: { "session-new": { type: "running" } } })) await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") expect(data.session.status("session-new")).toBe("running") expect(requests.event).toBe(2) - expect(data.connection.status()).toBe("connected") - expect(data.connection.attempt()).toBe(0) - expect(data.connection.error()).toBeUndefined() + expect(sdk.connection.status()).toBe("connected") + expect(sdk.connection.attempt()).toBe(0) + expect(sdk.connection.error()).toBeUndefined() } finally { app.renderer.destroy() } @@ -326,7 +663,7 @@ test("removes committed revert messages from local state", async () => { created: 3, type: "session.revert.committed", durable: durable(sessionID, 3), - data: { sessionID, messageID: "msg_002" }, + data: { sessionID, to: "msg_002" }, }) await wait(() => data.session.message.ids(sessionID).length === 1) @@ -338,7 +675,7 @@ test("removes committed revert messages from local state", async () => { } }) -test("connectedOnce is false until first connect and persists across disconnect", async () => { +test("distinguishes initial connection from reconnection", async () => { const encoder = new TextEncoder() let stream: ReadableStreamDefaultController | undefined const eventResponse = () => @@ -364,10 +701,10 @@ test("connectedOnce is false until first connect and persists across disconnect" const calls = createFetch((url) => { if (url.pathname === "/api/event") return eventResponse() }) - let data!: ReturnType + let sdk!: ReturnType function Probe() { - data = useData() + sdk = useSDK() return } @@ -385,16 +722,13 @@ test("connectedOnce is false until first connect and persists across disconnect" try { await wait(() => stream !== undefined) - expect(data.connection.status()).toBe("connecting") - expect(data.connection.connectedOnce()).toBe(false) + expect(sdk.connection.status()).toBe("connecting") connect() - await wait(() => data.connection.status() === "connected") - expect(data.connection.connectedOnce()).toBe(true) + await wait(() => sdk.connection.status() === "connected") disconnect() - await wait(() => data.connection.status() === "connecting") - expect(data.connection.connectedOnce()).toBe(true) + await wait(() => sdk.connection.status() === "reconnecting") } finally { app.renderer.destroy() } @@ -402,14 +736,44 @@ test("connectedOnce is false until first connect and persists across disconnect" test("tracks session status from active sessions and execution events", async () => { const events = createEventStream() + let settled = false const calls = createFetch((url) => { - if (url.pathname === "/api/session/active") - return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/session-live") + return json({ + data: { + id: "session-live", + projectID: "proj_test", + cost: settled ? 0.75 : 0, + tokens: settled + ? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } } + : { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Live session", + location: { directory }, + }, + }) + if (url.pathname === "/api/session/session-failed") + return json({ + data: { + id: "session-failed", + projectID: "proj_test", + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Failed session", + location: { directory }, + }, + }) }, events) let data!: ReturnType + let rows!: SessionRow[] + let manualRows!: SessionRow[] function Probe() { data = useData() + rows = createSessionRows(() => "session-retry") + manualRows = createSessionRows(() => "session-manual") return } @@ -428,6 +792,17 @@ test("tracks session status from active sessions and execution events", async () try { await wait(() => data.session.status("session-active") === "running") expect(data.session.status("session-idle")).toBe("idle") + await data.session.refresh("session-live") + + settled = true + emitEvent(events, { + id: "evt_execution_started", + created: 0, + type: "session.execution.started", + durable: durable("session-live"), + data: { sessionID: "session-live" }, + }) + await wait(() => data.session.status("session-live") === "running") emitEvent(events, { id: "evt_step_started", @@ -441,38 +816,59 @@ test("tracks session status from active sessions and execution events", async () model: { id: "model", providerID: "provider" }, }, }) - await wait(() => data.session.status("session-live") === "running") - emitEvent(events, { id: "evt_step_ended", created: 0, type: "session.step.ended", - durable: durable("session-live", 1, 2), + durable: durable("session-live", 1), data: { sessionID: "session-live", assistantMessageID: "message-live", finish: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }, + }) + emitEvent(events, { + id: "evt_step_usage", + created: 0, + type: "session.usage.updated", + data: { + sessionID: "session-live", + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }, }) await wait(() => { const assistant = data.session.message.get("session-live", "message-live") return assistant?.type === "assistant" && assistant.finish === "stop" }) + await wait(() => data.session.get("session-live")?.cost === 0.75) expect(data.session.status("session-live")).toBe("running") + expect(data.session.get("session-live")).toMatchObject({ + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }) emitEvent(events, { - id: "evt_execution_settled", + id: "evt_execution_succeeded", created: 0, - type: "session.execution.settled", - data: { - sessionID: "session-live", - outcome: "success", - }, + type: "session.execution.succeeded", + durable: durable("session-live", 1), + data: { sessionID: "session-live" }, }) await wait(() => data.session.status("session-live") === "idle") + await data.session.refresh("session-failed") + emitEvent(events, { + id: "evt_failed_execution_started", + created: 0, + type: "session.execution.started", + durable: durable("session-failed"), + data: { sessionID: "session-failed" }, + }) + await wait(() => data.session.status("session-failed") === "running") + emitEvent(events, { id: "evt_failed_step_started", created: 0, @@ -485,36 +881,212 @@ test("tracks session status from active sessions and execution events", async () model: { id: "model", providerID: "provider" }, }, }) - await wait(() => data.session.status("session-failed") === "running") - emitEvent(events, { id: "evt_step_failed", created: 0, type: "session.step.failed", - durable: durable("session-failed", 1, 2), + durable: durable("session-failed", 1), data: { sessionID: "session-failed", assistantMessageID: "message-failed", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.content-filter", message: "Provider blocked the response" }, + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + }, + }) + emitEvent(events, { + id: "evt_failed_step_usage", + created: 0, + type: "session.usage.updated", + data: { + sessionID: "session-failed", + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, }, }) await wait(() => { const assistant = data.session.message.get("session-failed", "message-failed") - return assistant?.type === "assistant" && assistant.finish === "error" + return ( + assistant?.type === "assistant" && + assistant.finish === "error" && + assistant.error?.type === "provider.content-filter" + ) + }) + await wait(() => data.session.get("session-failed")?.cost === 0.25) + expect(data.session.get("session-failed")?.tokens).toEqual({ + input: 5, + output: 1, + reasoning: 1, + cache: { read: 1, write: 0 }, }) expect(data.session.status("session-failed")).toBe("running") emitEvent(events, { - id: "evt_failed_execution_settled", + id: "evt_failed_execution_failed", created: 0, - type: "session.execution.settled", + type: "session.execution.failed", + durable: durable("session-failed", 1), data: { sessionID: "session-failed", - outcome: "failure", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.content-filter", message: "Provider blocked the response" }, }, }) await wait(() => data.session.status("session-failed") === "idle") + + emitEvent(events, { + id: "evt_retry_execution_started", + created: 0, + type: "session.execution.started", + durable: durable("session-retry"), + data: { sessionID: "session-retry" }, + }) + emitEvent(events, { + id: "evt_retry_step_started", + created: 0, + type: "session.step.started", + durable: durable("session-retry", 1), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitEvent(events, { + id: "evt_retry_scheduled", + created: 0, + type: "session.retry.scheduled", + durable: durable("session-retry", 1), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + attempt: 2, + at: 2_000, + error: { type: "provider.transport", message: "Disconnected" }, + }, + }) + await wait(() => { + const assistant = data.session.message.get("session-retry", "message-retry") + return assistant?.type === "assistant" && assistant.retry?.attempt === 2 + }) + await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) + emitEvent(events, { + id: "evt_retry_next_step", + created: 2_000, + type: "session.step.started", + durable: durable("session-retry", 1), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => { + const assistant = data.session.message.get("session-retry", "message-retry") + return assistant?.type === "assistant" && assistant.retry === undefined + }) + await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) + expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1) + emitEvent(events, { + id: "evt_retry_scheduled_again", + created: 2_000, + type: "session.retry.scheduled", + durable: durable("session-retry", 1), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + attempt: 3, + at: 6_000, + error: { type: "provider.transport", message: "Disconnected again" }, + }, + }) + await wait(() => { + const assistant = data.session.message.get("session-retry", "message-retry") + return assistant?.type === "assistant" && assistant.retry?.attempt === 3 + }) + emitEvent(events, { + id: "evt_retry_interrupted", + created: 2_000, + type: "session.execution.interrupted", + durable: durable("session-retry", 1), + data: { sessionID: "session-retry", reason: "shutdown" }, + }) + 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_started", + created: 1, + type: "session.compaction.started", + durable: durable("session-manual", 2), + data: { sessionID: "session-manual", reason: "manual", recent: "", inputID: "message-compaction" }, + }) + emitEvent(events, { + id: "evt_manual_compaction_delta", + created: 2, + type: "session.compaction.delta", + data: { sessionID: "session-manual", text: "Streamed summary" }, + }) + await wait(() => { + const message = data.session.message.get("session-manual", "message-compaction") + return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" + }) + emitEvent(events, { + id: "evt_manual_compaction_ended", + created: 3, + type: "session.compaction.ended", + durable: durable("session-manual", 4), + data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" }, + }) + await wait(() => { + const message = data.session.message.get("session-manual", "message-compaction") + return message?.type === "compaction" && message.status === "completed" + }) + expect(manualRows.filter((row) => row.type === "message")).toEqual([ + { type: "message", messageID: "message-compaction" }, + ]) + + emitEvent(events, { + id: "evt_compaction_started", + created: 0, + type: "session.compaction.started", + durable: durable("session-live", 2), + data: { sessionID: "session-live", reason: "auto", recent: "" }, + }) + emitEvent(events, { + id: "evt_compaction_delta_1", + created: 0, + type: "session.compaction.delta", + data: { sessionID: "session-live", text: "Live " }, + }) + emitEvent(events, { + id: "evt_compaction_delta_2", + created: 0, + type: "session.compaction.delta", + data: { sessionID: "session-live", text: "summary" }, + }) + await wait(() => { + const message = data.session.message.get("session-live", "msg_compaction_started") + return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" + }) + + emitEvent(events, { + id: "evt_compaction_ended", + created: 0, + type: "session.compaction.ended", + durable: durable("session-live", 5), + data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" }, + }) + await wait(() => { + const message = data.session.message.get("session-live", "msg_compaction_started") + return message?.type === "compaction" && message.status === "completed" + }) + expect(data.session.message.get("session-live", "msg_compaction_started")).toMatchObject({ + type: "compaction", + status: "completed", + summary: "Live summary", + }) } finally { app.renderer.destroy() } @@ -796,9 +1368,11 @@ test("adds and dismisses permission requests from live events", async () => { const events = createEventStream() const calls = createFetch(undefined, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -815,7 +1389,7 @@ test("adds and dismisses permission requests from live events", async () => { )) try { - await wait(() => data.connection.status() === "connected") + await wait(() => sdk.connection.status() === "connected") emitEvent(events, { id: "evt_permission_asked_1", created: 0, @@ -861,6 +1435,52 @@ test("adds and dismisses permission requests from live events", async () => { } }) +test("reconciles all pending permission requests when the event stream reconnects", async () => { + const events = createEventStream() + let requests = [ + { id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] }, + { id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] }, + ] + let calls = 0 + const fetch = createFetch((url) => { + if (url.pathname !== "/api/permission/request") return + calls++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old") + expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep") + + requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }] + events.disconnect() + + await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new") + expect(data.session.permission.list("ses_old")).toBeUndefined() + expect(data.session.permission.list("ses_keep")).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("adds, dismisses, and refreshes form requests", async () => { const events = createEventStream() const calls = createFetch((url) => { @@ -868,9 +1488,11 @@ test("adds, dismisses, and refreshes form requests", async () => { return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] }) }, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -887,7 +1509,7 @@ test("adds, dismisses, and refreshes form requests", async () => { )) try { - await wait(() => data.connection.status() === "connected") + await wait(() => sdk.connection.status() === "connected") emitEvent(events, { id: "evt_form_created_1", created: 0, @@ -931,6 +1553,52 @@ test("adds, dismisses, and refreshes form requests", async () => { } }) +test("reconciles all pending form requests when the event stream reconnects", async () => { + const events = createEventStream() + let requests = [ + { id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] }, + { id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" }, + ] + let calls = 0 + const fetch = createFetch((url) => { + if (url.pathname !== "/api/form/request") return + calls++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") + expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") + + requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }] + events.disconnect() + + await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new") + expect(data.session.form.list("ses_old")).toBeUndefined() + expect(data.session.form.list("ses_keep")).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("settles pending tools when a live failure arrives", async () => { const events = createEventStream() const calls = createFetch((url) => { @@ -1021,9 +1689,9 @@ test("settles pending tools when a live failure arrives", async () => { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", - tool: "bash", input: {}, - provider: { executed: false, metadata: { fake: { call: true } } }, + executed: false, + state: { call: true }, }, }) emitEvent(events, { @@ -1036,7 +1704,8 @@ test("settles pending tools when a live failure arrives", async () => { assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", error: { type: "unknown", message: "aborted" }, - provider: { executed: false, metadata: { fake: { result: true } } }, + executed: false, + resultState: { result: true }, }, }) @@ -1062,11 +1731,9 @@ test("settles pending tools when a live failure arrives", async () => { expect(tool.state.input).toEqual({}) expect(tool.state.structured).toEqual({}) expect(tool.state.content).toEqual([]) - expect(tool.provider).toEqual({ - executed: false, - metadata: { fake: { call: true } }, - resultMetadata: { fake: { result: true } }, - }) + expect(tool.executed).toBe(false) + expect(tool.providerState).toEqual({ call: true }) + expect(tool.providerResultState).toEqual({ result: true }) expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([ "agent-switched", "model-switched", @@ -1082,7 +1749,7 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts immediately with queued marker and clears when promoted", async () => { +test("renders admitted prompts immediately and tracks them until promoted", async () => { const events = createEventStream() const sessionID = "session-1" const messageID = "msg_user_1" @@ -1135,10 +1802,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr }) await wait(() => sync.session.message.list(sessionID)?.length === 1) const admitted = sync.session.message.list(sessionID)?.[0] - expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello", metadata: { queued: true } }) + expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" }) + expect(admitted?.metadata).toBeUndefined() + expect(sync.session.input.list(sessionID)).toEqual([messageID]) await sync.session.message.refresh(sessionID) - expect(sync.session.message.list(sessionID)?.[0]?.metadata?.queued).toBeUndefined() + expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() emitEvent(events, { id: "evt_prompted_1", @@ -1158,7 +1827,8 @@ test("renders admitted prompts immediately with queued marker and clears when pr expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: messageID, text: "hello" }) - expect(message.metadata?.queued).toBeUndefined() + expect(message.metadata).toBeUndefined() + 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.get(sessionID, messageID)).toBe(message) diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index f9a657ecc4..c95e3834d2 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -89,6 +89,19 @@ function FailedCompleteToolFixture() { ) } +function ReminderAlignmentFixture() { + return ( + + + Switched variant to medium + + + Instructions updated + + + ) +} + async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { testSetup = await testRender(component, options) await testSetup.renderOnce() @@ -109,6 +122,8 @@ describe("TUI inline tool wrapping", () => { // Legacy tool names normalize to their renamed views. expect(toolDisplay("bash")).toBe("shell") expect(toolDisplay("task")).toBe("subagent") + expect(toolDisplay("apply_patch")).toBe("patch") + expect(toolDisplay("patch")).toBe("patch") expect(toolDisplay("plugin_tool")).toBe("generic") }) @@ -124,6 +139,12 @@ describe("TUI inline tool wrapping", () => { expect(frame).not.toContain("Read failed") }) + test("aligns switch reminders with instruction reminders", async () => { + expect(await renderFrame(() => , { width: 35, height: 2 })).toBe( + " Switched variant to medium\n ◈ Instructions updated", + ) + }) + test("filters malformed nested tool wire data", () => { expect( parseApplyPatchFiles([ diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index fec6f5eb87..c43a6faf9d 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,24 +1,24 @@ import { expect, test } from "bun:test" -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" import { reduceSessionRows } from "../../../src/routes/session/rows" test("groups exploration parts across assistant messages until a delimiter", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, assistant("assistant-1", [ - { type: "text", id: "text-1", text: "Looking" }, + { type: "text", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, { type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } }, ]), assistant("assistant-2", [ { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } }, - { type: "text", id: "text-2", text: "Done" }, + { type: "text", text: "Done" }, ]), ] expect(reduceSessionRows(messages)).toEqual([ { type: "message", messageID: "user-1" }, - { type: "part", ref: { messageID: "assistant-1", partID: "text-1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { type: "group", kind: "exploration", @@ -30,12 +30,12 @@ test("groups exploration parts across assistant messages until a delimiter", () { messageID: "assistant-2", partID: "grep-1" }, ], }, - { type: "part", ref: { messageID: "assistant-2", partID: "text-2" } }, + { type: "part", ref: { messageID: "assistant-2", partID: "text:0" } }, ]) }) test("keeps non-exploration tools as individual part rows", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, { type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } }, @@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => { ]) }) -test("groups across empty assistant reasoning parts", () => { - const messages: SessionMessage[] = [ +test("assigns stable kind ordinals within an assistant message", () => { + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ - { type: "reasoning", id: "reasoning-1", text: "Looking" }, + { type: "text", text: "First" }, + { type: "reasoning", text: "Think" }, + { type: "text", text: "Second" }, + { type: "reasoning", text: "Check" }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "text:1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } }, + ]) +}) + +test("groups across empty assistant reasoning parts", () => { + const messages: SessionMessageInfo[] = [ + assistant("assistant-1", [ + { type: "reasoning", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, ]), assistant("assistant-2", [ - { type: "reasoning", id: "reasoning-2", text: "" }, + { type: "reasoning", text: "" }, { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, ]), ] expect(reduceSessionRows(messages)).toEqual([ - { type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, { type: "group", kind: "exploration", @@ -94,10 +112,8 @@ test("completes exploration groups when another row follows", () => { { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, ]) finished.finish = "stop" - const messages: SessionMessage[] = [ - assistant("assistant-1", [ - { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, - ]), + const messages: SessionMessageInfo[] = [ + assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "user", id: "user-1", text: "Continue", time: { created: 2 } }, finished, ] @@ -123,12 +139,11 @@ test("completes exploration groups when another row follows", () => { }) test("hides synthetic messages without descriptions", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "synthetic", id: "synthetic-1", - sessionID: "session-1", text: "internal context", time: { created: 2 }, }, @@ -150,12 +165,11 @@ test("hides synthetic messages without descriptions", () => { }) test("renders synthetic messages with descriptions", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "synthetic", id: "synthetic-1", - sessionID: "session-1", text: "internal context", description: "Explicit notice", time: { created: 2 }, @@ -182,6 +196,45 @@ test("renders synthetic messages with descriptions", () => { ]) }) +test("renders a footer for a pre-output retry assistant after replay", () => { + const message = assistant("assistant-retry", []) + message.retry = { + attempt: 2, + at: 2_000, + error: { type: "provider.transport", message: "Disconnected" }, + } + + expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) +}) + +test("places a running compaction barrier before every queued user message", () => { + const queued = (id: string, text: string, created: number): SessionMessageInfo => ({ + type: "user", + id, + text, + time: { created }, + }) + const messages: SessionMessageInfo[] = [ + queued("user-before", "Before", 1), + { + type: "compaction", + id: "compaction", + status: "running", + reason: "manual", + summary: "", + recent: "", + time: { created: 2 }, + }, + queued("user-after", "After", 3), + ] + + expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([ + { type: "message", messageID: "compaction" }, + { type: "message", messageID: "user-before" }, + { type: "message", messageID: "user-after" }, + ]) +}) + function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { return { type: "assistant", @@ -194,5 +247,5 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses } function pending() { - return { status: "pending" as const, input: "" } + return { status: "streaming" as const, input: "" } } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 8c6e476db0..2c10ed43a2 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -90,7 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { test("recognizes generated parent and child titles", () => { @@ -7,4 +8,22 @@ describe("util.session", () => { expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue() expect(isDefaultTitle("New session - custom")).toBeFalse() }) + + 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) + expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10) + expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined() + expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) + }) }) diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index f77f54cbab..ca08e7d974 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -31,7 +31,7 @@ describe("toolDisplayMetadata", () => { }) test("does not expose pending or malformed metadata", () => { - expect(toolDisplayMetadata({ status: "pending", structured: { provider: "exa" } })).toEqual({}) + expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({}) expect(toolDisplayMetadata({ status: "completed" })).toEqual({}) expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({}) expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({}) diff --git a/packages/web/src/content/docs/ar/cli.mdx b/packages/web/src/content/docs/ar/cli.mdx index 5aa9b781f9..e592b55ef5 100644 --- a/packages/web/src/content/docs/ar/cli.mdx +++ b/packages/web/src/content/docs/ar/cli.mdx @@ -608,7 +608,6 @@ opencode upgrade v0.1.48 | `OPENCODE_EXPERIMENTAL_EXA` | boolean | تفعيل ميزات Exa التجريبية | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | تمكين TY LSP لملفات python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | تفعيل وضع الخطة | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | تفعيل مهام subagent في الخلفية | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | تفعيل نظام الأحداث التجريبي | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | تفعيل مسار طلبات LLM الأصلي | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | تفعيل تنفيذ بحث الويب بالتوازي | diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx index 8883e6889a..a6e8366ca6 100644 --- a/packages/web/src/content/docs/bs/cli.mdx +++ b/packages/web/src/content/docs/bs/cli.mdx @@ -606,7 +606,6 @@ Ove varijable okruženja omogućavaju eksperimentalne karakteristike koje se mog | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Omogući eksperimentalne Exa funkcije | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Omogući TY LSP za python datoteke | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Omogući Plan mod | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Omogući pozadinske zadatke subagenata | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Omogući eksperimentalni sistem događaja | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Omogući nativnu putanju LLM zahtjeva | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Omogući paralelno izvršavanje web pretrage | diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 94d9ba3c75..7484521364 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -725,7 +725,6 @@ These environment variables enable experimental features that may change or be r | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable TY LSP for python files | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Enable background subagent tasks | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Enable experimental event system | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Enable native LLM request path | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Enable parallel web search execution | diff --git a/packages/web/src/content/docs/da/cli.mdx b/packages/web/src/content/docs/da/cli.mdx index 814d2b819e..3d998904cb 100644 --- a/packages/web/src/content/docs/da/cli.mdx +++ b/packages/web/src/content/docs/da/cli.mdx @@ -609,7 +609,6 @@ Disse miljøvariabler muliggør eksperimentelle funktioner, der kan ændres elle | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Aktive eksperimenter Exa-funktioner | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Aktiver TY LSP for python-filer | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Aktiver plantilstand | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Aktiver baggrundsopgaver for subagenter | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Aktiver eksperimentelt hændelsessystem | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Aktiver native LLM-anmodningssti | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Aktiver parallel udførelse af websøgning | diff --git a/packages/web/src/content/docs/de/cli.mdx b/packages/web/src/content/docs/de/cli.mdx index 2c100e7c79..fe04a17fd3 100644 --- a/packages/web/src/content/docs/de/cli.mdx +++ b/packages/web/src/content/docs/de/cli.mdx @@ -608,7 +608,6 @@ Diese Umgebungsvariablen ermöglichen experimentelle Funktionen, die sich änder | `OPENCODE_EXPERIMENTAL_EXA` | boolescher Wert | Experimentelle Exa-Funktionen aktivieren | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolescher Wert | TY LSP für Python-Dateien aktivieren | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolescher Wert | Planmodus aktivieren | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolescher Wert | Hintergrundaufgaben für Subagenten aktivieren | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolescher Wert | Experimentelles Ereignissystem aktivieren | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolescher Wert | Nativen LLM-Anfragepfad aktivieren | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolescher Wert | Parallele Websuche aktivieren | diff --git a/packages/web/src/content/docs/es/cli.mdx b/packages/web/src/content/docs/es/cli.mdx index b925385cff..f9b597e05e 100644 --- a/packages/web/src/content/docs/es/cli.mdx +++ b/packages/web/src/content/docs/es/cli.mdx @@ -608,7 +608,6 @@ Estas variables de entorno habilitan funciones experimentales que pueden cambiar | `OPENCODE_EXPERIMENTAL_EXA` | booleano | Habilitar funciones experimentales de Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | booleano | Habilitar Habilitar TY LSP para archivos python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | booleano | Habilitar modo de plan | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | booleano | Habilitar tareas de subagentes en segundo plano | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | booleano | Habilitar sistema de eventos experimental | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | booleano | Habilitar ruta nativa de solicitud LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | booleano | Habilitar ejecución paralela de búsqueda web | diff --git a/packages/web/src/content/docs/fr/cli.mdx b/packages/web/src/content/docs/fr/cli.mdx index bc8b550cc0..3c75d9929b 100644 --- a/packages/web/src/content/docs/fr/cli.mdx +++ b/packages/web/src/content/docs/fr/cli.mdx @@ -609,7 +609,6 @@ Ces variables d'environnement activent des fonctionnalités expérimentales qui | `OPENCODE_EXPERIMENTAL_EXA` | booléen | Activer les fonctionnalités Exa expérimentales | | `OPENCODE_EXPERIMENTAL_LSP_TY` | booléen | Activer TY LSP pour les fichiers python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | booléen | Activer le mode plan | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | booléen | Activer les tâches de sous-agents en arrière-plan | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | booléen | Activer le système d'événements expérimental | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | booléen | Activer le chemin de requête LLM natif | | `OPENCODE_EXPERIMENTAL_PARALLEL` | booléen | Activer l'exécution parallèle de la recherche web | diff --git a/packages/web/src/content/docs/it/cli.mdx b/packages/web/src/content/docs/it/cli.mdx index 67cd703a9f..46fe713827 100644 --- a/packages/web/src/content/docs/it/cli.mdx +++ b/packages/web/src/content/docs/it/cli.mdx @@ -609,7 +609,6 @@ Queste variabili d'ambiente abilitano funzionalità sperimentali che potrebbero | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Abilita funzionalità Exa sperimentali | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Abilita TY LSP per i file python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Abilita plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Abilita task subagent in background | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Abilita sistema eventi sperimentale | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Abilita percorso nativo di richiesta LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Abilita esecuzione parallela della ricerca web | diff --git a/packages/web/src/content/docs/ja/cli.mdx b/packages/web/src/content/docs/ja/cli.mdx index 755b856fc7..5da4b9bedd 100644 --- a/packages/web/src/content/docs/ja/cli.mdx +++ b/packages/web/src/content/docs/ja/cli.mdx @@ -608,7 +608,6 @@ OpenCode は環境変数を使用して構成できます。 | `OPENCODE_EXPERIMENTAL_EXA` | ブール値 | 実験的な Exa 機能を有効にする | | `OPENCODE_EXPERIMENTAL_LSP_TY` | ブール値 | python ファイルの TY LSP を有効にする | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | ブール値 | プランモードを有効にする | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | ブール値 | バックグラウンド subagent タスクを有効にする | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | ブール値 | 実験的なイベントシステムを有効にする | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | ブール値 | ネイティブ LLM リクエスト経路を有効にする | | `OPENCODE_EXPERIMENTAL_PARALLEL` | ブール値 | 並列 Web 検索実行を有効にする | diff --git a/packages/web/src/content/docs/ko/cli.mdx b/packages/web/src/content/docs/ko/cli.mdx index e6ee7e1f02..1cf8900f91 100644 --- a/packages/web/src/content/docs/ko/cli.mdx +++ b/packages/web/src/content/docs/ko/cli.mdx @@ -608,7 +608,6 @@ OpenCode는 환경 변수로도 구성할 수 있습니다. | `OPENCODE_EXPERIMENTAL_EXA` | boolean | 실험적 Exa 기능 활성화 | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | python 파일에 대해 TY LSP 활성화 | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Plan mode 활성화 | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 백그라운드 subagent 작업 활성화 | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 실험적 이벤트 시스템 활성화 | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 네이티브 LLM 요청 경로 활성화 | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 병렬 웹 검색 실행 활성화 | diff --git a/packages/web/src/content/docs/nb/cli.mdx b/packages/web/src/content/docs/nb/cli.mdx index 36e9485e97..77308b45bc 100644 --- a/packages/web/src/content/docs/nb/cli.mdx +++ b/packages/web/src/content/docs/nb/cli.mdx @@ -609,7 +609,6 @@ Disse miljøvariablene muliggjør eksperimentelle funksjoner som kan endres elle | `OPENCODE_EXPERIMENTAL_EXA` | boolsk | Aktiver eksperimentelle Exa-funksjoner | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolsk | Aktiver TY LSP for python-filer | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolsk | Aktiver planmodus | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolsk | Aktiver bakgrunnsoppgaver for subagenter | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolsk | Aktiver eksperimentelt hendelsessystem | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolsk | Aktiver innebygd LLM-forespørselsvei | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolsk | Aktiver parallell kjøring av websøk | diff --git a/packages/web/src/content/docs/pl/cli.mdx b/packages/web/src/content/docs/pl/cli.mdx index eee6d3e162..8769a14d76 100644 --- a/packages/web/src/content/docs/pl/cli.mdx +++ b/packages/web/src/content/docs/pl/cli.mdx @@ -609,7 +609,6 @@ Te zmienne włączają funkcje eksperymentalne, które mogą ulec zmianie lub zo | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Włącz funkcje eksperymentalne Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Włącz TY LSP dla plików python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Włącz tryb planowania | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Włącz zadania subagentów w tle | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Włącz eksperymentalny system zdarzeń | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Włącz natywną ścieżkę żądań LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Włącz równoległe wykonywanie wyszukiwania web | diff --git a/packages/web/src/content/docs/pt-br/cli.mdx b/packages/web/src/content/docs/pt-br/cli.mdx index 69b69e1f28..4a073c4b4b 100644 --- a/packages/web/src/content/docs/pt-br/cli.mdx +++ b/packages/web/src/content/docs/pt-br/cli.mdx @@ -608,7 +608,6 @@ Essas variáveis de ambiente habilitam recursos experimentais que podem mudar ou | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Habilitar recursos experimentais do Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Habilitar TY LSP para arquivos python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Habilitar modo de plano | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Habilitar tarefas de subagentes em segundo plano | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Habilitar sistema de eventos experimental | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Habilitar caminho nativo de requisição LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Habilitar execução paralela de busca web | diff --git a/packages/web/src/content/docs/ru/cli.mdx b/packages/web/src/content/docs/ru/cli.mdx index ac48039aa8..ed75453988 100644 --- a/packages/web/src/content/docs/ru/cli.mdx +++ b/packages/web/src/content/docs/ru/cli.mdx @@ -609,7 +609,6 @@ opencode можно настроить с помощью переменных с | `OPENCODE_EXPERIMENTAL_EXA` | логическое значение | Включить экспериментальные функции Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | логическое значение | Включить TY LSP для файлов python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | логическое значение | Включить режим плана | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | логическое значение | Включить фоновые задачи субагентов | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | логическое значение | Включить экспериментальную систему событий | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | логическое значение | Включить нативный путь запросов LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | логическое значение | Включить параллельное выполнение веб-поиска | diff --git a/packages/web/src/content/docs/th/cli.mdx b/packages/web/src/content/docs/th/cli.mdx index 49df515c81..39a67bdb36 100644 --- a/packages/web/src/content/docs/th/cli.mdx +++ b/packages/web/src/content/docs/th/cli.mdx @@ -610,7 +610,6 @@ OpenCode สามารถกำหนดค่าโดยใช้ตัว | `OPENCODE_EXPERIMENTAL_EXA` | Boolean | ฟีเจอร์ Exa ทดลอง | | `OPENCODE_EXPERIMENTAL_LSP_TY` | Boolean | เปิดใช้งาน TY LSP สำหรับไฟล์ python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | Boolean | เปิดใช้งาน Plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | Boolean | เปิดใช้งานงาน subagent เบื้องหลัง | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | Boolean | เปิดใช้งานระบบเหตุการณ์ทดลอง | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | Boolean | เปิดใช้งานเส้นทางคำขอ LLM แบบ native | | `OPENCODE_EXPERIMENTAL_PARALLEL` | Boolean | เปิดใช้งานการค้นหาเว็บแบบขนาน | diff --git a/packages/web/src/content/docs/tr/cli.mdx b/packages/web/src/content/docs/tr/cli.mdx index 4f28122bd9..8974771e06 100644 --- a/packages/web/src/content/docs/tr/cli.mdx +++ b/packages/web/src/content/docs/tr/cli.mdx @@ -609,7 +609,6 @@ Bu ortam değişkenleri değişebilecek veya kaldırılabilecek deneysel özelli | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Deneysel Exa özelliklerini etkinleştirin | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | python dosyaları için TY LSP'yi etkinleştir | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Plan modunu etkinleştir | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Arka plan alt ajan görevlerini etkinleştir | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Deneysel olay sistemini etkinleştir | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Yerel LLM istek yolunu etkinleştir | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Paralel web araması yürütmesini etkinleştir | diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx index 46f090bb72..b3d9702e3d 100644 --- a/packages/web/src/content/docs/zh-cn/cli.mdx +++ b/packages/web/src/content/docs/zh-cn/cli.mdx @@ -609,7 +609,6 @@ OpenCode 可以通过环境变量进行配置。 | `OPENCODE_EXPERIMENTAL_EXA` | boolean | 启用实验性 Exa 功能 | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | 为 python 文件启用 TY LSP | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | 启用计划模式 | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 启用后台子代理任务 | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 启用实验性事件系统 | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 启用原生 LLM 请求路径 | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 启用并行 Web 搜索执行 | diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx index 25e7bce88e..619c7030d3 100644 --- a/packages/web/src/content/docs/zh-tw/cli.mdx +++ b/packages/web/src/content/docs/zh-tw/cli.mdx @@ -609,7 +609,6 @@ OpenCode 可以透過環境變數進行設定。 | `OPENCODE_EXPERIMENTAL_EXA` | boolean | 啟用實驗性 Exa 功能 | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | 為 python 檔案啟用 TY LSP | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | 啟用計畫模式 | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 啟用背景子代理任務 | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 啟用實驗性事件系統 | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 啟用原生 LLM 請求路徑 | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 啟用平行 Web 搜尋執行 | diff --git a/script/profile-typecheck-packages.ts b/script/profile-typecheck-packages.ts new file mode 100644 index 0000000000..0d03b1c1bd --- /dev/null +++ b/script/profile-typecheck-packages.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env bun + +import path from "path" + +const root = path.resolve(import.meta.dir, "..") +const proc = Bun.spawn( + [ + "bun", + "turbo", + "typecheck", + "--concurrency=1", + "--force", + "--continue=always", + "--summarize", + "--output-logs=errors-only", + ], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }, +) +const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) +const output = stdout + stderr +if (exitCode !== 0) { + process.stdout.write(stdout) + process.stderr.write(stderr) + process.exit(exitCode) +} + +const summary = output.match(/Summary:\s+(.+\.json)/)?.[1]?.trim() +if (!summary) { + process.stdout.write(stdout) + process.stderr.write(stderr) + throw new Error("Turbo did not report a run summary") +} + +const report = (await Bun.file(summary).json()) as { + tasks: Array<{ + taskId: string + execution: { startTime: number; endTime: number; exitCode: number } | null + }> +} +const tasks = report.tasks + .flatMap((task) => + task.execution + ? [ + { + task: task.taskId.replace(/#typecheck$/, ""), + durationMs: task.execution.endTime - task.execution.startTime, + }, + ] + : [], + ) + .sort((a, b) => b.durationMs - a.durationMs) +const total = tasks.reduce((duration, task) => duration + task.durationMs, 0) +const width = Math.max(...tasks.map((task) => task.task.length), "Package".length) + +console.log(`Package${" ".repeat(width - "Package".length)} Time Share`) +tasks.forEach((task) => { + const duration = `${(task.durationMs / 1000).toFixed(2)}s`.padStart(7) + const share = `${((task.durationMs / total) * 100).toFixed(1)}%`.padStart(6) + console.log(`${task.task.padEnd(width)} ${duration} ${share}`) +}) +console.log(`\nTotal serial task time: ${(total / 1000).toFixed(2)}s`) +console.log(`Turbo summary: ${path.relative(root, summary)}`) diff --git a/script/profile-typecheck.ts b/script/profile-typecheck.ts new file mode 100644 index 0000000000..ac093847bc --- /dev/null +++ b/script/profile-typecheck.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun + +import { mkdir } from "fs/promises" +import path from "path" + +if (process.platform !== "darwin") throw new Error("System typecheck profiling currently supports macOS only") + +const root = path.resolve(import.meta.dir, "..") +const startedAt = new Date() +const args = Bun.argv.slice(2) +const command = [ + "bun", + "turbo", + "typecheck", + ...(args.some((arg) => arg.startsWith("--concurrency")) ? [] : ["--concurrency=3"]), + ...args, +] +const before = systemSnapshot() +const proc = Bun.spawn(command, { + cwd: root, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}) +const samples = [processTreeSnapshot(proc.pid, startedAt)] +const timer = setInterval(() => samples.push(processTreeSnapshot(proc.pid, startedAt)), 200) +const exitCode = await proc.exited +clearInterval(timer) +samples.push(processTreeSnapshot(proc.pid, startedAt)) + +const finishedAt = new Date() +const after = systemSnapshot() +const active = samples.filter((sample) => sample.processes > 0) +const report = { + command, + cwd: root, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationSeconds: (finishedAt.getTime() - startedAt.getTime()) / 1000, + exitCode, + summary: { + peakCpuPercent: Math.max(0, ...active.map((sample) => sample.cpuPercent)), + averageCpuPercent: average(active.map((sample) => sample.cpuPercent)), + peakAggregateRssMB: Math.max(0, ...active.map((sample) => sample.aggregateRssMB)), + peakProcesses: Math.max(0, ...active.map((sample) => sample.processes)), + peakTsgoRelatedProcesses: Math.max(0, ...active.map((sample) => sample.tsgoRelatedProcesses)), + swapDeltaMB: after.swapUsedMB - before.swapUsedMB, + compressedMemoryDeltaMB: after.compressedMemoryMB - before.compressedMemoryMB, + pageoutDelta: after.pageouts - before.pageouts, + }, + system: { before, after }, + samples, +} + +const directory = path.join(root, ".typecheck-profiles") +const file = path.join(directory, `${startedAt.toISOString().replaceAll(":", "-")}.json`) +await mkdir(directory, { recursive: true }) +await Bun.write(file, JSON.stringify(report, null, 2) + "\n") + +console.log(` +Typecheck profile + Duration: ${report.durationSeconds.toFixed(1)}s + Average CPU: ${report.summary.averageCpuPercent.toFixed(0)}% + Peak CPU: ${report.summary.peakCpuPercent.toFixed(0)}% + Aggregate RSS: ${report.summary.peakAggregateRssMB.toFixed(0)} MB + Peak processes: ${report.summary.peakProcesses} (${report.summary.peakTsgoRelatedProcesses} tsgo-related) + Swap delta: ${signed(report.summary.swapDeltaMB)} MB + Compressed: ${signed(report.summary.compressedMemoryDeltaMB)} MB + Pageouts: ${signed(report.summary.pageoutDelta)} + Report: ${path.relative(root, file)} +`) + +process.exit(exitCode) + +function processTreeSnapshot(rootPID: number, startedAt: Date) { + const processes = processList() + const pids = new Set([rootPID]) + const pending = [rootPID] + while (pending.length > 0) { + const parent = pending.shift() + processes + .filter((process) => process.ppid === parent && !pids.has(process.pid)) + .forEach((process) => { + pids.add(process.pid) + pending.push(process.pid) + }) + } + const tree = processes.filter((process) => pids.has(process.pid)) + return { + elapsedSeconds: (Date.now() - startedAt.getTime()) / 1000, + processes: tree.length, + tsgoRelatedProcesses: tree.filter((process) => /\btsgo\b/.test(process.command)).length, + cpuPercent: sum(tree.map((process) => process.cpuPercent)), + aggregateRssMB: sum(tree.map((process) => process.rssKB)) / 1024, + } +} + +function systemSnapshot() { + const vm = text(["vm_stat"]) + const pageSize = Number(vm.match(/page size of (\d+) bytes/)?.[1] ?? 4096) + const fields = Object.fromEntries( + vm + .split("\n") + .map((line) => line.match(/^([^:]+):\s+(\d+)\.?$/)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => [match[1], Number(match[2])]), + ) + const swap = text(["sysctl", "-n", "vm.swapusage"]) + return { + loadAverage: text(["sysctl", "-n", "vm.loadavg"]).trim(), + thermalState: text(["pmset", "-g", "therm"]).trim(), + swapUsedMB: Number(swap.match(/used = ([\d.]+)M/)?.[1] ?? 0), + freeMemoryMB: ((fields["Pages free"] ?? 0) * pageSize) / 1024 / 1024, + compressedMemoryMB: ((fields["Pages occupied by compressor"] ?? 0) * pageSize) / 1024 / 1024, + pageouts: fields.Pageouts ?? 0, + relevantProcesses: processList() + .filter((process) => + /opencode|tsgo|tsserver|vtsls|eslintServer|tailwindcss-language-server/.test(process.command), + ) + .sort((a, b) => b.rssKB - a.rssKB) + .map(processSummary), + topCpuProcesses: processList() + .sort((a, b) => b.cpuPercent - a.cpuPercent) + .slice(0, 15) + .map(processSummary), + topMemoryProcesses: processList() + .sort((a, b) => b.rssKB - a.rssKB) + .slice(0, 15) + .map(processSummary), + } +} + +function processSummary(process: ReturnType[number]) { + return { + pid: process.pid, + ppid: process.ppid, + rssMB: process.rssKB / 1024, + cpuPercent: process.cpuPercent, + command: process.command, + } +} + +function processList() { + return text(["ps", "-axo", "pid=,ppid=,rss=,%cpu=,command="]) + .split("\n") + .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => ({ + pid: Number(match[1]), + ppid: Number(match[2]), + rssKB: Number(match[3]), + cpuPercent: Number(match[4]), + command: match[5], + })) +} + +function text(command: string[]) { + return Bun.spawnSync(command).stdout.toString() +} + +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +function average(values: number[]) { + if (values.length === 0) return 0 + return sum(values) / values.length +} + +function signed(value: number) { + return `${value >= 0 ? "+" : ""}${value.toFixed(0)}` +} diff --git a/specs/v2/config.md b/specs/v2/config.md index 96b0f3ac90..9a7afee3a0 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -312,12 +312,12 @@ External protocol and server integration configuration. Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as timeout defaults can live under the same subsystem. -MCP timeouts have separate startup and request budgets, expressed in milliseconds. `startup` covers establishing the transport and completing MCP initialization. `request` applies independently to each post-initialization MCP request. A server may override either default without repeating the other. +MCP timeouts have separate startup, catalog, and execution budgets, expressed in milliseconds. `startup` covers establishing the transport and completing MCP initialization. `catalog` applies independently to discovery requests such as listing tools and prompts. `execution` covers potentially interactive operations such as tool calls and prompt evaluation. A server may override any default without repeating the others. ```jsonc { "mcp": { - "timeout": { "startup": 30000, "request": 300000 }, + "timeout": { "startup": 30000, "catalog": 30000, "execution": 43200000 }, "servers": { "github": { "type": "local", @@ -338,7 +338,7 @@ MCP timeouts have separate startup and request budgets, expressed in millisecond "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback", }, "disabled": false, - "timeout": { "request": 600000 }, + "timeout": { "execution": 600000 }, }, }, }, @@ -380,7 +380,7 @@ Fields that should not be ported by inertia; each needs an explicit justificatio | `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | | `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | | `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | -| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout.request` for the default and `mcp.servers..timeout.request` for per-server overrides. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Migrate to both `mcp.timeout.catalog` and `mcp.timeout.execution`, with corresponding per-server overrides. | ## Review Order diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 62c85ef3e8..fa7170ffbd 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -13,6 +13,28 @@ Compatibility: - Existing changelog entries retain the names that were accurate when those changes occurred. - Behavior is unchanged: this is a vocabulary and contract rename only. +## 2026-07-04: Canonicalize Generated Shell Type Name + +- Collapse the legacy JavaScript SDK generator's equivalent `Shell1V2` component into the canonical `ShellV2` contract. + +Compatibility: + +- The Shell wire shape is unchanged. Generated event and endpoint types now consistently reference `ShellV2`. + +## 2026-07-03: Add Execution Lifecycle, Retry, And Structured Session Errors + +- Replace live-only `session.execution.settled` and unused `session.retried` with durable v1 `session.execution.started`, `session.execution.succeeded`, `session.execution.failed`, `session.execution.interrupted`, and `session.retry.scheduled` events. +- Add an open `SessionError` wire envelope with dot-cased type values and the browser-safe `FinishReason` contract. +- Project retry state onto the current assistant and classify content-filter finishes as failed steps. +- Reuse one projected assistant across pre-output retry steps; each provider call remains a distinct step and consumes agent allowance. + +Compatibility: + +- Experimental V2 event, sequence, input, and message-projection rows are reset. Durable event contracts restart at v1. +- `SessionError.type` remains an open string so new error classifications do not require event-version or database migrations. Unknown fields are ignored by older decoders; richer public details require a separate compatibility design. +- Execution lifecycle events are historical observations of one process-local coordinator busy period. Unmatched starts never establish current liveness or recovery work; `/api/session/active` remains the current-process liveness authority. +- Scheduled retries are historical UI state after a crash and never trigger provider recovery. + ## 2026-07-03: Require Durable Envelope On Durable Events - Make the wire `durable` envelope required on durable event definitions. @@ -952,3 +974,17 @@ Compatibility: Compatibility: - V2 durable events and projections are experimental and are reset by `20260703190000_reset_v2_shell_event_payloads`; existing V2 event rows, event sequences, projected session messages, and admitted inputs are wiped. + +## 2026-07-03: Simplify Assistant Fragments And Provider State + +- Remove provider block IDs from current Session text and reasoning event payloads and projected content. A Session assistant step allows at most one open fragment of each kind; events carry a Session-assigned kind-specific ordinal so live updates and hydrated projections share the stable `(assistantMessageID, kind, ordinal)` reference. Tool correlation continues to use `callID`. +- Replace nested `providerMetadata` with opaque, provider-un-nested `state` at the Session boundary. Reasoning uses `state`; tool calls use `state`, settlements use `resultState`, and projected tools expose `providerState` and `providerResultState`. Replay re-nests state under the selected provider only for the same successful model. +- Flatten `executed` on tool events and projected tools, and remove the redundant tool name from `session.tool.called` because `session.tool.input.started` owns it. +- Rename `session.revert.committed.messageID` to `to`, paired with `session.forked.from`. +- Publish live-only `session.compaction.delta` events for accepted summary chunks while `session.compaction.ended.1` remains the durable full summary. +- OpenAI Responses closes output text on `response.output_text.done` or its message `response.output_item.done` boundary. Provider documentation and recorded stream shapes show sequential output items, not valid overlapping text or reasoning blocks of the same kind. + +Compatibility: + +- All changed durable definitions remain version 1. `20260703200000_reset_v2_session_events` performs the single reset for this Session event contract update, wiping experimental V2 events, sequences, projected messages, and admitted inputs. +- Promise, Effect, and legacy JavaScript SDK surfaces are regenerated from the simplified schemas. diff --git a/specs/v2/session.md b/specs/v2/session.md index 1fea99dcb6..511ece8cfc 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -21,7 +21,7 @@ sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) sessions.interrupt(sessionID) -> interrupts active execution on this process - -> waits for runner cleanup and settlement + -> waits for runner cleanup and a terminal lifecycle observation -> clears a coalesced follow-up wake already registered with this coordinator -> preserves durable inbox rows for a later wake or resume -> idle or missing Session is a no-op @@ -32,7 +32,7 @@ sessions.active() -> absence means inactive; activity is not durable across process restarts ``` -`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts. +`session_input` is the typed durable admission inbox for prompts and Session control operations. `PromptAdmitted` records accepted user input; `Compaction.Admitted` records one coalesced manual compaction barrier. Admitted prompts remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. A pending compaction blocks all unpromoted prompts, runs before the Session would otherwise become idle, and releases the backlog only after its durable ended or failed event settles the barrier. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts. `admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history. @@ -47,7 +47,15 @@ SessionExecution.resume(sessionID) `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across steps. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every owned tool fiber after provider-stream closure, and reloads projected history once before continuation. For every in-process step, `session.step.started` precedes its tool calls, every local and hosted call settles as `session.tool.success` or `session.tool.failed`, and only then may the runner publish the single terminal `session.step.ended` or `session.step.failed`. Streamed provider-error evidence is retained until this closeout; thrown provider failures and interruption use the same settlement-first ordering. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once. + +`callID` is unique only within its owning step, not across the Session. Tool events therefore carry `assistantMessageID`, and consumers correlate a call through the step that owns that assistant message rather than inventing a synthetic composite key. Before assembling a provider request, the runner's cross-drain `failInterruptedTools` recovery durably fails any tool still projected as pending or running from a previous process with `Tool execution interrupted`. This orphan-recovery sweep is the explicit nesting exception: it occurs in a later drain, but attributes every settlement to the original `assistantMessageID`; abandoned side effects are never silently replayed. + +`session.execution.started.1` and exactly one of `session.execution.succeeded.1`, `session.execution.failed.1`, or `session.execution.interrupted.1` observe one process-local coordinator busy period, including coalesced drains and joined resumes. These durable rows are history, not a durable execution identity: replay must never infer current liveness, recovery, grouping, or resumability from an unmatched start. A drain has no durable identity or transcript boundary. `/api/session/active` is the authority for current process-local liveness, and is empty after restart. User interruption records `reason: "user"`; owner-scope interruption defaults to `"shutdown"`; `"superseded"` is reserved for explicit replacement. + +Core retries only typed rate-limit, provider-internal, and transport failures before durable assistant text, reasoning, tool-call, tool-output, or tool-execution evidence. The initial call plus at most four retries use two-second exponential backoff, raised when a provider's `retryAfterMs` is larger. Every retry attempt remains a distinct step and consumes the selected agent's step allowance, while all pre-output attempts reuse one assistant message ID so retry state never creates empty transcript messages. Repeated `session.step.started.1` facts reopen that assistant projection idempotently. `session.retry.scheduled.1` is committed before each delay with the upcoming one-based attempt and absolute epoch-millisecond time, then projects onto `Assistant.retry`. The next `session.step.started.1` or terminal failure/interruption clears it. A scheduled retry surviving a crash is historical UI state only and never triggers recovery. + +A normalized `step-finish` with `content-filter` publishes `session.step.failed.1` with `provider.content-filter`, never `session.step.ended.1`. Any partial streamed content remains visible; a contentless filtered response still has a failed assistant projection. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. @@ -112,7 +120,6 @@ Current instruction follow-ups: - Add configured and remote instruction sources with explicit precedence and removal semantics. - Add durable post-crash continuation recovery for promoted or provider-dispatched work. -- Add explicit manual compaction on top of automatic request-budget compaction. - Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth. - Consider watcher-backed per-file caching only if measurements show direct step-boundary observation is too expensive. - Design any plugin-defined instruction contribution as an explicit runner composition boundary; do not reintroduce a registry implicitly. @@ -124,7 +131,13 @@ Before each step, the runner estimates the complete model-visible request and co Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. -`session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. A failed or interrupted attempt therefore leaves the previous history boundary active. +The rolling summary is a continuation checkpoint with this complete heading order: `Objective`, `Important Details`, `Work State`, and `Next Move`. `Work State` records completed, active, and blocked work, while `Next Move` records the immediate and following actions. Every heading remains present even when its value is `(none)`. + +`session.compaction.admitted.1` durably records a manual request and projects its queued transcript row. `session.compaction.started.1` identifies the attempt and transforms that row into a running divider. Compaction deltas are live-only progress rendered beneath it. `session.compaction.ended.1` durably stores the final summary and serialized recent context, completes the same row, and settles the manual barrier. `session.compaction.failed.1` settles an unsuccessful manual barrier without changing the previous history boundary. On the next physical attempt, the runner observes a completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. + +Assistant text and reasoning follow a strict `started` / live-only `delta` / durable full-value `ended` lifecycle. A publisher permits at most one open fragment of each kind in a step and fails on a second start before the matching end. Provider block IDs remain internal to LLM adapters; each fragment event carries a Session-assigned kind-specific ordinal, matching the ordinal derived from projected content. UI identity is therefore the assistant message ID plus content kind and ordinal. Tool calls retain step-scoped `callID` because settlements and provider replay correlate through it. + +Provider continuation state is opaque and un-nested at the Session boundary. The publisher selects only the active model provider's entry from LLM provider metadata. Same-model replay re-nests that state under the current provider; model switches and failed assistant steps continue to suppress provider-native continuation state. Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step. @@ -160,7 +173,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o | Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. | | Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. | -Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. +Provider timeout and watchdog policy is intentionally deferred. Retry tuning beyond the narrow safe policy above remains separate work; the runner does not impose a universal provider-stream inactivity or absolute timeout. Inbox delivery is explicit: @@ -174,7 +187,7 @@ Execution has two entry points: Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need. -A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. +A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. Its durable lifecycle events are historical observations only; they do not replace the coordinator's process-local active registry. The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 6f09684a1b..7c6a137192 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -24,6 +24,8 @@ through legacy `SessionPrompt.loop(...)`: and issues one explicit `llm.stream(request)` step at a time - durable V2 projections record text, reasoning, provider failures, tool calls, tool results, and assistant output +- owned local tool fibers and unresolved hosted calls settle before their step's + single terminal event; cross-drain orphan recovery retains original assistant attribution - a scoped `ToolRegistry` advertises definitions and the first permission-checked `read` built-in - local continuation reloads projected history, and promoting new user input resets the selected agent's configured step allowance @@ -37,9 +39,6 @@ a FIFO until the Session would otherwise become idle and then promote one at a t Next reviewed slices: -- preserve eager structured local-tool settlement: durably record each complete - call, start its child execution immediately, await every settlement after the - step closes, then reload projected history once - revisit per-step tool-call limits, output truncation, and operational backpressure before broadening exposure; eager local execution is deliberately unbounded in the current local slice while SQLite publication stays serialized @@ -55,7 +54,7 @@ Next reviewed slices: ### Deferred durable continuation recovery -Do not infer that ambiguous provider work is safe to retry from an advisory wake. +Do not infer that ambiguous provider work is safe to retry from an advisory wake, an unmatched historical `session.execution.started` event, or a surviving `session.retry.scheduled` projection. The first inbox-driven runner intentionally omits outer physical-attempt markers until they have a concrete consumer and a complete recovery policy.