From e610b1d39074e4c82705968fd8bcd085e1191412 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 8 Jun 2026 10:58:10 +0200 Subject: [PATCH] refactor: kilo compat for v1.14.48 --- .opencode-version | 2 +- AGENTS.md | 1 + bun.lock | 38 +- nix/hashes.json | 8 +- package.json | 7 +- packages/core/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +- packages/http-recorder/README.md | 214 ++ packages/http-recorder/package.json | 2 +- packages/http-recorder/src/cassette.ts | 198 +- packages/http-recorder/src/diff.ts | 95 - packages/http-recorder/src/effect.ts | 185 +- packages/http-recorder/src/index.ts | 30 +- packages/http-recorder/src/matching.ts | 90 +- packages/http-recorder/src/recorder.ts | 73 + packages/http-recorder/src/redaction.ts | 15 +- packages/http-recorder/src/redactor.ts | 76 + packages/http-recorder/src/schema.ts | 5 +- packages/http-recorder/src/storage.ts | 34 - packages/http-recorder/src/websocket.ts | 149 +- packages/http-recorder/sst-env.d.ts | 10 + .../http-recorder/test/record-replay.test.ts | 165 +- packages/llm/example/tutorial.ts | 2 +- packages/llm/package.json | 2 +- .../llm/src/protocols/anthropic-messages.ts | 117 +- .../llm/src/protocols/bedrock-converse.ts | 90 +- packages/llm/src/protocols/gemini.ts | 12 +- packages/llm/src/protocols/openai-chat.ts | 9 +- .../llm/src/protocols/openai-responses.ts | 44 +- .../llm/src/protocols/utils/bedrock-cache.ts | 31 +- packages/llm/src/protocols/utils/cache.ts | 16 + .../llm/src/protocols/utils/tool-stream.ts | 38 +- packages/llm/src/schema/events.ts | 117 +- packages/llm/src/schema/ids.ts | 9 + packages/llm/src/schema/messages.ts | 3 + packages/llm/src/tool-runtime.ts | 20 +- packages/llm/sst-env.d.ts | 10 + packages/llm/test/adapter.test.ts | 4 +- packages/llm/test/llm.test.ts | 2 +- .../anthropic-messages-cache.recorded.test.ts | 48 + .../anthropic-messages.recorded.test.ts | 3 +- .../test/provider/anthropic-messages.test.ts | 132 +- .../bedrock-converse-cache.recorded.test.ts | 50 + .../test/provider/bedrock-converse.test.ts | 71 + .../provider/gemini-cache.recorded.test.ts | 47 + packages/llm/test/provider/gemini.test.ts | 6 +- .../llm/test/provider/golden.recorded.test.ts | 7 +- .../llm/test/provider/openai-chat.test.ts | 4 +- .../openai-responses-cache.recorded.test.ts | 44 + .../test/provider/openai-responses.test.ts | 6 +- packages/llm/test/recorded-scenarios.ts | 12 + packages/llm/test/recorded-test.ts | 2 +- packages/llm/test/recorded-websocket.ts | 5 +- packages/opencode/AGENTS.md | 7 + .../migration.sql | 4 + .../snapshot.json | 1490 +++++++++++++ packages/opencode/package.json | 30 +- .../specs/openapi-translation-cleanup.md | 12 +- packages/opencode/src/agent/agent.ts | 87 +- packages/opencode/src/audio.d.ts | 5 + packages/opencode/src/cli/cmd/tui/app.tsx | 17 +- .../cli/cmd/tui/component/prompt/index.tsx | 1 - .../cli/cmd/tui/component/prompt/traits.ts | 9 +- .../src/cli/cmd/tui/context/path-format.tsx | 39 + packages/opencode/src/cli/cmd/tui/keymap.tsx | 38 +- .../src/cli/cmd/tui/routes/session/index.tsx | 439 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 33 +- .../src/cli/cmd/tui/validate-session.ts | 13 +- packages/opencode/src/config/attachment.ts | 30 + packages/opencode/src/config/config.ts | 4 + packages/opencode/src/control-plane/schema.ts | 6 +- .../opencode/src/control-plane/workspace.ts | 14 +- packages/opencode/src/data-migration.sql.ts | 6 + packages/opencode/src/data-migration.ts | 59 + packages/opencode/src/effect/app-runtime.ts | 4 + packages/opencode/src/id/id.ts | 8 - packages/opencode/src/image/image.ts | 180 ++ packages/opencode/src/permission/schema.ts | 4 +- packages/opencode/src/project/bootstrap.ts | 5 +- packages/opencode/src/pty/schema.ts | 4 +- packages/opencode/src/question/schema.ts | 7 +- packages/opencode/src/reference/reference.ts | 237 ++ .../src/reference/repository-cache.ts | 147 ++ .../src/server/routes/instance/httpapi/api.ts | 3 + .../routes/instance/httpapi/groups/query.ts | 4 + .../instance/httpapi/handlers/session.ts | 47 +- .../httpapi/middleware/schema-error.ts | 30 + .../server/routes/instance/httpapi/public.ts | 51 +- .../server/routes/instance/httpapi/server.ts | 3 + packages/opencode/src/session/compaction.ts | 48 +- packages/opencode/src/session/processor.ts | 304 +-- packages/opencode/src/session/prompt.ts | 108 +- packages/opencode/src/session/schema.ts | 10 +- packages/opencode/src/sync/schema.ts | 4 +- packages/opencode/src/tool/glob.ts | 8 +- packages/opencode/src/tool/grep.ts | 4 + packages/opencode/src/tool/read.ts | 5 +- packages/opencode/src/tool/registry.ts | 3 + packages/opencode/src/tool/repo_clone.ts | 165 +- packages/opencode/src/tool/schema.ts | 4 +- packages/opencode/src/util/repository.ts | 15 + packages/opencode/src/v2/event.ts | 10 - packages/opencode/src/v2/session.ts | 8 +- packages/opencode/test/agent/agent.test.ts | 17 +- .../test/cli/cmd/tui/prompt-traits.test.ts | 25 +- .../opencode/test/cli/github-action.test.ts | 24 +- packages/opencode/test/image/image.test.ts | 82 + .../test/project/migrate-global.test.ts | 13 +- .../opencode/test/reference/reference.test.ts | 244 +++ .../server/httpapi-query-schema-drift.test.ts | 61 +- .../server/httpapi-schema-error-body.test.ts | 162 ++ .../opencode/test/server/httpapi-ui.test.ts | 2 +- .../test/server/sdk-error-shape.test.ts | 22 +- .../opencode/test/server/sdk-v1-smoke.test.ts | 60 + .../opencode/test/session/compaction.test.ts | 1935 +++++++---------- .../opencode/test/session/instruction.test.ts | 16 +- packages/opencode/test/session/llm.test.ts | 16 +- .../opencode/test/session/message-v2.test.ts | 4 +- .../test/session/processor-effect.test.ts | 5 +- packages/opencode/test/session/prompt.test.ts | 12 +- .../test/session/schema-decoding.test.ts | 14 +- .../test/session/snapshot-tool-race.test.ts | 12 +- .../opencode/test/tool/apply_patch.test.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 2 +- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 4 +- packages/opencode/test/tool/grep.test.ts | 4 +- packages/opencode/test/tool/lsp.test.ts | 2 +- packages/opencode/test/tool/question.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 89 +- packages/opencode/test/tool/registry.test.ts | 2 + .../opencode/test/tool/repo_clone.test.ts | 2 +- .../opencode/test/tool/repo_overview.test.ts | 2 +- packages/opencode/test/tool/shell.test.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 2 +- packages/opencode/test/tool/webfetch.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/sdk/js/src/gen/types.gen.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 22 +- packages/sdk/openapi.json | 686 ++++-- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- .../@silvia-odwyer%2Fphoton-node@0.3.4.patch | 14 + 146 files changed, 6744 insertions(+), 2992 deletions(-) create mode 100644 packages/http-recorder/README.md delete mode 100644 packages/http-recorder/src/diff.ts create mode 100644 packages/http-recorder/src/recorder.ts create mode 100644 packages/http-recorder/src/redactor.ts delete mode 100644 packages/http-recorder/src/storage.ts create mode 100644 packages/http-recorder/sst-env.d.ts create mode 100644 packages/llm/src/protocols/utils/cache.ts create mode 100644 packages/llm/sst-env.d.ts create mode 100644 packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts create mode 100644 packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts create mode 100644 packages/llm/test/provider/gemini-cache.recorded.test.ts create mode 100644 packages/llm/test/provider/openai-responses-cache.recorded.test.ts create mode 100644 packages/opencode/migration/20260511000411_data_migration_state/migration.sql create mode 100644 packages/opencode/migration/20260511000411_data_migration_state/snapshot.json create mode 100644 packages/opencode/src/cli/cmd/tui/context/path-format.tsx create mode 100644 packages/opencode/src/config/attachment.ts create mode 100644 packages/opencode/src/data-migration.sql.ts create mode 100644 packages/opencode/src/data-migration.ts create mode 100644 packages/opencode/src/image/image.ts create mode 100644 packages/opencode/src/reference/reference.ts create mode 100644 packages/opencode/src/reference/repository-cache.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts create mode 100644 packages/opencode/test/image/image.test.ts create mode 100644 packages/opencode/test/reference/reference.test.ts create mode 100644 packages/opencode/test/server/httpapi-schema-error-body.test.ts create mode 100644 packages/opencode/test/server/sdk-v1-smoke.test.ts create mode 100644 patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch diff --git a/.opencode-version b/.opencode-version index dfbc2e6537..6ce43629e9 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.46 +v1.14.48 diff --git a/AGENTS.md b/AGENTS.md index 44d08ae955..7913ddabd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ ### General Principles - Keep things in one function unless composable or reusable +- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller. - Avoid `try`/`catch` where possible - Avoid using the `any` type - Use Bun APIs when possible, like `Bun.file()` diff --git a/bun.lock b/bun.lock index 9dcad8f126..c3758e2326 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.46", + "version": "1.14.48", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.46", + "version": "1.14.48", "bin": { "opencode": "./bin/opencode", }, @@ -412,6 +412,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -495,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +534,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +549,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +584,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +633,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.46", + "version": "1.14.48", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -677,6 +678,7 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@types/bun": "catalog:", @@ -2035,6 +2037,8 @@ "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 558264474a..4244e0c0e7 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=", - "aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=", - "aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=", - "x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k=" + "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", + "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", + "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", + "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" } } diff --git a/package.json b/package.json index 2c24470b0c..1ae0cc0113 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@effect/opentelemetry": "4.0.0-beta.57", "@effect/platform-node": "4.0.0-beta.57", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.12", + "@types/bun": "1.3.14", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", @@ -37,7 +37,7 @@ "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", - "@types/node": "24.12.2", + "@types/node": "24.12.4", "@types/semver": "7.7.1", "@tsconfig/node22": "22.0.2", "@tsconfig/bun": "1.0.9", @@ -147,10 +147,11 @@ }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.3.22", + "version": "7.3.40", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index ddfaf9d4af..8b052f80be 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.22", + "version": "7.3.40", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 6a2272ebf3..fca4f7c7e5 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.3.22" +version = "7.3.40" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.40/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.40/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.40/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.40/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.40/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md new file mode 100644 index 0000000000..f6aaed4358 --- /dev/null +++ b/packages/http-recorder/README.md @@ -0,0 +1,214 @@ +# @opencode-ai/http-recorder + +Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests +exercise real request shapes against deterministic, version-controlled +cassettes — no manual mocks, no flakes from upstream drift. + +## Install + +Internal package; depended on as `@opencode-ai/http-recorder` from another +workspace package. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +``` + +## Quickstart + +Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`. +By default the layer records on first run and replays on subsequent runs — +no env-var ternary at the call site, and `CI=true` forces strict replay so +missing cassettes fail loudly in CI rather than silently re-recording. + +```ts +import { Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpRecorder } from "@opencode-ai/http-recorder" + +const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1")) + return yield* response.json +}) + +// Records if the cassette is missing, replays if it exists. +// In CI (CI=true) always replays — fails loudly on missing fixtures. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) + +// Force a refresh — always hits upstream and overwrites. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" })))) +``` + +## Modes + +| Mode | Behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. | +| `replay` | Strict — match the request to a recorded interaction; error if none. | +| `record` | Execute upstream, append the interaction, write the cassette. | +| `passthrough` | Bypass the recorder entirely — just call upstream. | + +## Cassette format + +A cassette is JSON at `test/fixtures/recordings/.json`: + +```json +{ + "version": 1, + "metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." }, + "interactions": [ + { + "transport": "http", + "request": { "method": "GET", "url": "...", "headers": {...}, "body": "" }, + "response": { "status": 200, "headers": {...}, "body": "..." } + } + ] +} +``` + +Cassettes are normal source files — review them, diff them, commit them. + +## Request matching + +By default, requests match on canonicalized method, URL, headers, and JSON +body (object keys sorted). Two dispatch strategies are available: + +- **`match`** (default) — find the first recorded interaction whose request + matches the incoming request. Same request twice returns the same response. +- **`sequential`** — return interactions in the order they were recorded, + validating each one matches as the cursor advances. Use for ordered flows + where the same URL is hit multiple times with meaningful state changes + (pagination, retries, polling). + +```ts +HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" }) +``` + +Supply your own matcher via `match: (incoming, recorded) => boolean` for +custom equivalence (e.g. ignoring a timestamp field in the body). + +## Redaction & secret safety + +Cassettes get checked in, so the recorder is aggressive about not letting +secrets escape. Redaction is configured by composing a `Redactor`: + +```ts +import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" + +HttpRecorder.cassetteLayer("anthropic/messages", { + redactor: Redactor.defaults({ + requestHeaders: { allow: ["content-type", "anthropic-version"] }, + url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") }, + body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }), + }), +}) +``` + +`Redactor.defaults({ … })` composes the four built-in redactors with your +overrides. For full control, build the stack yourself: + +```ts +const redactor = Redactor.compose( + Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }), + Redactor.responseHeaders(), + Redactor.url({ query: ["session-id"] }), + Redactor.body((parsed) => /* … */), +) +``` + +What each layer does: + +- **`requestHeaders` / `responseHeaders`** — strip headers to a small + allow-list (request default: `content-type`, `accept`, `openai-beta`; + response default: `content-type`). Sensitive headers within the + allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens, + …) are replaced with `[REDACTED]`. +- **`url`** — query parameters matching common secret names (`api_key`, + `token`, `signature`, AWS signing params, …) are replaced with + `[REDACTED]`. URL user/password are replaced. `transform` runs after + built-in redaction for path-level scrubbing. +- **`body`** — receives the parsed JSON request body and returns a redacted + version. No-op for non-JSON bodies. + +After assembling the cassette, the recorder scans every string for known +secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys, +AWS access keys, GitHub tokens, PEM blocks) and for values matching any +environment variable named like a credential. If anything is found, the +cassette is **not written** and the request fails with `UnsafeCassetteError` +listing what was detected. + +## WebSocket recording + +WebSocket support records the open frame plus client/server message +streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions +can live in the same cassette. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const program = Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "ws/subscribe", + cassette, + live: liveExecutor, + }) + // use executor.open(...) +}) +``` + +## Inspecting cassettes programmatically + +`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read` +returns the recorded interactions for a name; the file format is hidden +behind the seam. Useful for CI checks: + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const audit = Effect.gen(function* () { + const cassettes = yield* HttpRecorder.Cassette.Service + const entries = yield* cassettes.list() + const issues = yield* Effect.forEach(entries, (entry) => + cassettes + .read(entry.name) + .pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))), + ) + return issues.filter((i) => i.findings.length > 0) +}) +``` + +`cassetteLayer` is the batteries-included entry point — it provides +`Cassette.fileSystem({ directory })` automatically. If you want to provide +your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's +own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` / +`Cassette.memory` yourself. + +## Options reference + +```ts +type RecordReplayOptions = { + mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay") + directory?: string // default: /test/fixtures/recordings + metadata?: Record // merged into cassette.metadata + redactor?: Redactor // default: Redactor.defaults() + dispatch?: "match" | "sequential" // default: "match" + match?: (incoming, recorded) => boolean // custom matcher +} +``` + +## Layout + +| File | Purpose | +| -------------- | -------------------------------------------------------------------------------- | +| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. | +| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. | +| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. | +| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. | +| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. | +| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | +| `schema.ts` | Effect Schema definitions for the cassette JSON format. | +| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | +| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 6c852c38d3..e17add2805 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.22", + "version": "7.3.40", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 769bcc7c70..3897f0222c 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -1,54 +1,76 @@ -import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect" +import { Context, Effect, FileSystem, Layer, Schema } from "effect" +import * as fs from "node:fs" import * as path from "node:path" -import { cassetteSecretFindings, type SecretFinding } from "./redaction" -import type { Cassette, CassetteMetadata, Interaction } from "./schema" -import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage" +import { secretFindings, type SecretFinding } from "./redaction" +import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" -export interface Entry { - readonly name: string - readonly path: string +const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + +export class CassetteNotFoundError extends Schema.TaggedErrorClass()("CassetteNotFoundError", { + cassetteName: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" not found` + } +} + +export interface AppendResult { + readonly findings: ReadonlyArray } export interface Interface { - readonly path: (name: string) => string - readonly read: (name: string) => Effect.Effect - readonly write: (name: string, cassette: Cassette) => Effect.Effect - readonly append: ( - name: string, - interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) => Effect.Effect< - { - readonly cassette: Cassette - readonly findings: ReadonlyArray - }, - PlatformError.PlatformError - > + readonly read: (name: string) => Effect.Effect, CassetteNotFoundError> + readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect readonly exists: (name: string) => Effect.Effect - readonly list: () => Effect.Effect, PlatformError.PlatformError> - readonly scan: (cassette: Cassette) => ReadonlyArray + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode-ai/http-recorder/Cassette") {} -export const layer = (options: { readonly directory?: string } = {}) => +export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`)) + +const buildCassette = ( + name: string, + interactions: ReadonlyArray, + metadata: CassetteMetadata | undefined, +): Cassette => ({ + version: 1, + metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, + interactions, +}) + +const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + +const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) + +export const fileSystem = ( + options: { readonly directory?: string } = {}, +): Layer.Layer => Layer.effect( Service, Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem + const fs = yield* FileSystem.FileSystem const directory = options.directory ?? DEFAULT_RECORDINGS_DIR - const recorded = yield* Ref.make(new Map>()) + const recorded = new Map() + const directoriesEnsured = new Set() - const pathFor = (name: string) => cassettePath(name, directory) + const cassettePath = (name: string) => path.join(directory, `${name}.json`) - const walk = (directory: string): Effect.Effect, PlatformError.PlatformError> => + const ensureDirectory = (name: string) => Effect.gen(function* () { - const entries = yield* fileSystem - .readDirectory(directory) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const dir = path.dirname(cassettePath(name)) + if (directoriesEnsured.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + directoriesEnsured.add(dir) + }) + + const walk = (current: string): Effect.Effect> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) const nested = yield* Effect.forEach(entries, (entry) => { - const full = path.join(directory, entry) - return fileSystem.stat(full).pipe( + const full = path.join(current, entry) + return fs.stat(full).pipe( Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), Effect.catch(() => Effect.succeed([] as string[])), ) @@ -56,53 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) => return nested.flat() }) - const read = Effect.fn("Cassette.read")(function* (name: string) { - return parseCassette(yield* fileSystem.readFileString(pathFor(name))) + return Service.of({ + read: (name) => + fs.readFileString(cassettePath(name)).pipe( + Effect.map((raw) => parseCassette(raw).interactions), + Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + ), + append: (name, interaction, metadata) => + Effect.gen(function* () { + const entry = recorded.get(name) ?? { interactions: [], findings: [] } + if (!recorded.has(name)) recorded.set(name, entry) + entry.interactions.push(interaction) + entry.findings.push(...secretFindings(interaction)) + const cassette = buildCassette(name, entry.interactions, metadata) + const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] + if (findings.length === 0) { + yield* ensureDirectory(name) + yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie) + } + return { findings } + }), + exists: (name) => + fs.access(cassettePath(name)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ), + list: () => + walk(directory).pipe( + Effect.map((files) => + files + .filter((file) => file.endsWith(".json")) + .map((file) => + path + .relative(directory, file) + .replace(/\\/g, "/") + .replace(/\.json$/, ""), + ) + .toSorted((a, b) => a.localeCompare(b)), + ), + ), }) - - const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { - yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true }) - yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) - }) - - const append = Effect.fn("Cassette.append")(function* ( - name: string, - interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) { - const interactions = yield* Ref.updateAndGet(recorded, (previous) => - new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]), - ) - const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata) - const findings = cassetteSecretFindings(cassette) - if (findings.length === 0) yield* write(name, cassette) - return { cassette, findings } - }) - - const exists = Effect.fn("Cassette.exists")(function* (name: string) { - return yield* fileSystem.access(pathFor(name)).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - }) - - const list = Effect.fn("Cassette.list")(function* () { - return (yield* walk(directory)) - .filter((file) => file.endsWith(".json")) - .map((file) => ({ - name: path - .relative(directory, file) - .replace(/\\/g, "/") - .replace(/\.json$/, ""), - path: file, - })) - .toSorted((a, b) => a.name.localeCompare(b.name)) - }) - - return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings }) }), ) -export const defaultLayer = layer() +export const memory = (initial: Record> = {}): Layer.Layer => + Layer.sync(Service, () => { + const stored = new Map( + Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]), + ) + const accumulatedFindings = new Map() -export * as Cassette from "./cassette" + return Service.of({ + read: (name) => + stored.has(name) + ? Effect.succeed(stored.get(name) ?? []) + : Effect.fail(new CassetteNotFoundError({ cassetteName: name })), + append: (name, interaction, metadata) => + Effect.sync(() => { + const existing = stored.get(name) + if (existing) existing.push(interaction) + else stored.set(name, [interaction]) + const findings = accumulatedFindings.get(name) + if (findings) findings.push(...secretFindings(interaction)) + else accumulatedFindings.set(name, [...secretFindings(interaction)]) + if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata })) + return { findings: accumulatedFindings.get(name) ?? [] } + }), + exists: (name) => Effect.sync(() => stored.has(name)), + list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()), + }) + }) diff --git a/packages/http-recorder/src/diff.ts b/packages/http-recorder/src/diff.ts deleted file mode 100644 index 29517befcb..0000000000 --- a/packages/http-recorder/src/diff.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Option } from "effect" -import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http" -import { decodeJson } from "./matching" -import { REDACTED, redactUrl, secretFindings } from "./redaction" -import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema" - -const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value) - if (!text) return String(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 ( - expected && - received && - typeof expected === "object" && - typeof received === "object" && - !Array.isArray(expected) && - !Array.isArray(received) - ) { - return [...new Set([...Object.keys(expected), ...Object.keys(received)])] - .toSorted() - .flatMap((key) => - valueDiffs( - (expected as Record)[key], - (received as Record)[key], - `${base}.${key}`, - limit, - ), - ) - .slice(0, limit) - } - 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)}`] -} - -const headerDiffs = (expected: Record, received: Record) => - [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { - if (expected[key] === received[key]) return [] - if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] - if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] - return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] - }) - -export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => { - const lines = [] - 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}`) - } - const headers = headerDiffs(expected.headers, received.headers) - if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) - const expectedBody = jsonBody(expected.body) - const receivedBody = jsonBody(received.body) - const body = - expectedBody !== undefined && receivedBody !== undefined - ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) - : expected.body === received.body - ? [] - : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] - if (body.length > 0) lines.push("body:", ...body) - return lines -} - -export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => { - const interactions = httpInteractions(cassette) - if (interactions.length === 0) return "cassette has no recorded HTTP interactions" - const ranked = interactions - .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) - .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) - const best = ranked[0] - return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") -} - -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index f103e45dc7..e6c3ccbc15 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -1,62 +1,37 @@ import { NodeFileSystem } from "@effect/platform-node" -import { Effect, Layer, Option, Ref } from "effect" +import { Effect, Layer, Option } from "effect" import { FetchHttpClient, + Headers, + HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, + UrlParams, } from "effect/unstable/http" -import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff" -import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching" -import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" -import { - httpInteractions, - type Cassette, - type CassetteMetadata, - type HttpInteraction, - type ResponseSnapshot, -} from "./schema" import * as CassetteService from "./cassette" +import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import { defaults, type Redactor } from "./redactor" +import { redactUrl } from "./redaction" +import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] - -export type RecordReplayMode = "record" | "replay" | "passthrough" +export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string readonly metadata?: CassetteMetadata - readonly redact?: { - readonly headers?: ReadonlyArray - readonly query?: ReadonlyArray - readonly url?: (url: string) => string - } - readonly requestHeaders?: ReadonlyArray - readonly responseHeaders?: ReadonlyArray - readonly redactBody?: (body: unknown) => unknown + readonly redactor?: Redactor readonly dispatch?: "match" | "sequential" readonly match?: RequestMatcher } -const responseHeaders = ( - response: HttpClientResponse.HttpClientResponse, - allow: ReadonlyArray, - redact: ReadonlyArray | undefined, -) => { - const merged = redactHeaders(response.headers as Record, allow, redact) - if (!merged["content-type"]) merged["content-type"] = "text/event-stream" - return merged -} - const BINARY_CONTENT_TYPES: ReadonlyArray = ["vnd.amazon.eventstream", "octet-stream"] -const isBinaryContentType = (contentType: string | undefined) => { - if (!contentType) return false - const lower = contentType.toLowerCase() - return BINARY_CONTENT_TYPES.some((token) => lower.includes(token)) -} +const isBinaryContentType = (contentType: string | undefined) => + contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token)) const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => isBinaryContentType(contentType) @@ -68,34 +43,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body -const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Fixture "${name}" not found. Run with RECORD=true to create it.`, - }), - }) +export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => + HttpClientRequest.makeWith( + request.method, + redactUrl(request.url), + UrlParams.empty, + Option.none(), + Headers.empty, + HttpBody.empty, + ) -const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) => +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`, - }), - }) - -const unsafeCassette = ( - request: HttpClientRequest.HttpClientRequest, - name: string, - findings: ReadonlyArray, -) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings - .map((item) => `${item.path} (${item.reason})`) - .join(", ")}`, - }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), }) export const recordingLayer = ( @@ -107,61 +67,22 @@ export const recordingLayer = ( Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient const cassetteService = yield* CassetteService.Service - const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS - const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS + const redactor = options.redactor ?? defaults() const match = options.match ?? defaultMatcher - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested const sequential = options.dispatch === "sequential" - const replay = yield* Ref.make(undefined) - const cursor = yield* Ref.make(0) + const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) - const raw = yield* Effect.promise(() => web.text()) - const body = options.redactBody - ? Option.match(decodeJson(raw), { - onNone: () => raw, - onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)), - }) - : raw - return { + return redactor.request({ method: web.method, - url: redactUrl(web.url, options.redact?.query, options.redact?.url), - headers: redactHeaders( - Object.fromEntries(web.headers.entries()), - requestHeadersAllow, - options.redact?.headers, - ), - body, - } - }) - - const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) => - Effect.gen(function* () { - const interactions = httpInteractions(cassette) - if (sequential) { - const index = yield* Ref.get(cursor) - 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") } - } - yield* Ref.update(cursor, (n) => n + 1) - return { interaction, detail: "" } - } - const interaction = interactions.find((candidate) => match(incoming, candidate.request)) - return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) } - }) - - const loadReplay = (request: HttpClientRequest.HttpClientRequest) => - Effect.gen(function* () { - const cached = yield* Ref.get(replay) - if (cached) return cached - const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name))) - yield* Ref.set(replay, cassette) - return cassette + url: web.url, + headers: Object.fromEntries(web.headers.entries()), + body: yield* Effect.promise(() => web.text()), + }) }) return HttpClient.make((request) => { @@ -169,18 +90,21 @@ export const recordingLayer = ( if (mode === "record") { return Effect.gen(function* () { - const currentRequest = yield* snapshotRequest(request) + const incoming = yield* snapshotRequest(request) const response = yield* upstream.execute(request) - const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers) - const captured = yield* captureResponseBody(response, headers["content-type"]) + const captured = yield* captureResponseBody(response, response.headers["content-type"]) const interaction: HttpInteraction = { transport: "http", - request: currentRequest, - response: { status: response.status, headers, ...captured }, + request: incoming, + response: redactor.response({ + status: response.status, + headers: response.headers as Record, + ...captured, + }), } - const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie) - const findings = result.findings - if (findings.length > 0) return yield* unsafeCassette(request, name, findings) + yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe( + Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))), + ) return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(interaction.response), interaction.response), @@ -189,14 +113,23 @@ export const recordingLayer = ( } return Effect.gen(function* () { - const cassette = yield* loadReplay(request) const incoming = yield* snapshotRequest(request) - const { interaction, detail } = yield* selectInteraction(cassette, incoming) - if (!interaction) return yield* fixtureMismatch(request, name, detail) - + const interactions = yield* replay.load.pipe( + Effect.mapError(() => + transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), + ), + ) + const result = sequential + ? selectSequential(interactions, incoming, match, yield* replay.cursor) + : selectMatch(interactions, incoming, match) + if (!result.interaction) + return yield* Effect.fail( + transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + ) + if (sequential) yield* replay.advance return HttpClientResponse.fromWeb( request, - new Response(decodeResponseBody(interaction.response), interaction.response), + new Response(decodeResponseBody(result.interaction.response), result.interaction.response), ) }) }) @@ -205,7 +138,7 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.layer({ directory: options.directory })), + Layer.provide(CassetteService.fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index d85e13bf4c..4b47e4513d 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,10 +1,26 @@ -export * from "./schema" -export * from "./redaction" -export * from "./matching" -export * from "./diff" -export * from "./storage" -export * from "./websocket" -export * from "./effect" +export type { + CassetteMetadata, + HttpInteraction, + Interaction, + RequestSnapshot, + ResponseSnapshot, + WebSocketFrame, + WebSocketInteraction, +} from "./schema" +export { CassetteNotFoundError, hasCassetteSync } from "./cassette" +export { defaultMatcher, type RequestMatcher } from "./matching" +export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction" +export { UnsafeCassetteError } from "./recorder" +export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" +export { + makeWebSocketExecutor, + type WebSocketConnection, + type WebSocketExecutor, + type WebSocketRecordReplayOptions, + type WebSocketRequest, +} from "./websocket" + export * as Cassette from "./cassette" +export * as Redactor from "./redactor" export * as HttpRecorder from "." diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts index b66c8fd146..9af85a2f3a 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/matching.ts @@ -1,5 +1,6 @@ import { Option, Schema } from "effect" -import type { RequestSnapshot } from "./schema" +import { REDACTED, secretFindings } from "./redaction" +import type { HttpInteraction, RequestSnapshot } from "./schema" const JsonValue = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownOption(JsonValue) @@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string => export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) + +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 String(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)) { + 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)) { + 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)}`] +} + +const headerDiffs = (expected: Record, received: Record) => + [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { + if (expected[key] === received[key]) return [] + if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] + if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] + return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] + }) + +export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray => { + const lines: string[] = [] + 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}`) + } + const headers = headerDiffs(expected.headers, received.headers) + if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) + const expectedBody = jsonBody(expected.body) + const receivedBody = jsonBody(received.body) + const body = + expectedBody !== undefined && receivedBody !== undefined + ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) + : expected.body === received.body + ? [] + : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] + if (body.length > 0) lines.push("body:", ...body) + return lines +} + +export const mismatchDetail = (interactions: ReadonlyArray, incoming: RequestSnapshot): string => { + if (interactions.length === 0) return "cassette has no recorded HTTP interactions" + const ranked = interactions + .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) + .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) + const best = ranked[0] + return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") +} + +export const selectMatch = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions.find((candidate) => match(incoming, candidate.request)) + return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) } +} + +export const selectSequential = ( + 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: "" } +} diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts new file mode 100644 index 0000000000..460b427c2a --- /dev/null +++ b/packages/http-recorder/src/recorder.ts @@ -0,0 +1,73 @@ +import { Effect, Ref, Schema, Scope } from "effect" +import type * as CassetteService from "./cassette" +import type { CassetteNotFoundError } from "./cassette" +import { SecretFindingSchema } from "./redaction" +import type { CassetteMetadata, Interaction } from "./schema" + +export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { + cassetteName: Schema.String, + findings: Schema.Array(SecretFindingSchema), +}) { + override get message() { + return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings + .map((finding) => `${finding.path} (${finding.reason})`) + .join(", ")}` + } +} + +export type ResolvedMode = "record" | "replay" | "passthrough" + +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 => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export const appendOrFail = ( + cassette: CassetteService.Interface, + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, +): Effect.Effect => + cassette + .append(name, interaction, metadata) + .pipe( + Effect.flatMap(({ findings }) => + findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), + ), + ) + +export interface ReplayState { + readonly load: Effect.Effect, CassetteNotFoundError> + readonly cursor: Effect.Effect + readonly advance: Effect.Effect +} + +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* Ref.make(0) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const used = yield* Ref.get(position) + if (used === 0) return + const interactions = yield* load.pipe(Effect.orDie) + if (used < interactions.length) + yield* Effect.die( + new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), + ) + }), + ) + + return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) } + }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 3a8b097839..b6aa8b3b87 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -1,5 +1,3 @@ -import type { Cassette } from "./schema" - export const REDACTED = "[REDACTED]" const DEFAULT_REDACT_HEADERS = [ @@ -97,10 +95,13 @@ export const redactHeaders = ( ) } -export type SecretFinding = { - readonly path: string - readonly reason: string -} +import { Schema } from "effect" + +export const SecretFindingSchema = Schema.Struct({ + path: Schema.String, + reason: Schema.String, +}) +export type SecretFinding = Schema.Schema.Type export const secretFindings = (value: unknown): ReadonlyArray => stringEntries(value).flatMap((entry) => [ @@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray => .filter((item) => entry.value.includes(item.value)) .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), ]) - -export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette) diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts new file mode 100644 index 0000000000..917ab05d09 --- /dev/null +++ b/packages/http-recorder/src/redactor.ts @@ -0,0 +1,76 @@ +import { Option } from "effect" +import { decodeJson } from "./matching" +import { redactHeaders, redactUrl } from "./redaction" +import type { RequestSnapshot, ResponseSnapshot } from "./schema" + +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 +} + +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/schema.ts b/packages/http-recorder/src/schema.ts index 2692b525b4..113769c7b7 100644 --- a/packages/http-recorder/src/schema.ts +++ b/packages/http-recorder/src/schema.ts @@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http export const isWebSocketInteraction = InteractionSchema.guards.websocket -export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction) +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) -export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) export const CassetteSchema = Schema.Struct({ version: Schema.Literal(1), diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts deleted file mode 100644 index 08dadb1bb9..0000000000 --- a/packages/http-recorder/src/storage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Option } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" - -export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") - -export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`) - -export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({ - name, - recordedAt: new Date().toISOString(), - ...(metadata ?? {}), -}) - -export const cassetteFor = ( - name: string, - interactions: ReadonlyArray, - metadata: CassetteMetadata | undefined, -): Cassette => ({ - version: 1, - metadata: metadataFor(name, metadata), - interactions, -}) - -export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - -export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) - -export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => { - const file = cassettePath(name, options.directory) - if (!fs.existsSync(file)) return false - return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8"))) -} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts index 8a854cb62c..f7529b4888 100644 --- a/packages/http-recorder/src/websocket.ts +++ b/packages/http-recorder/src/websocket.ts @@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect" import type { Headers } from "effect/unstable/http" import * as CassetteService from "./cassette" import { canonicalizeJson, decodeJson } from "./matching" -import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" -import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema" - -export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import type { RecordReplayMode } from "./effect" +import { defaults, type Redactor } from "./redactor" +import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" export interface WebSocketRequest { readonly url: string @@ -24,67 +24,36 @@ export interface WebSocketExecutor { export interface WebSocketRecordReplayOptions { readonly name: string - readonly mode?: "record" | "replay" | "passthrough" + readonly mode?: RecordReplayMode readonly metadata?: CassetteMetadata readonly cassette: CassetteService.Interface readonly live: WebSocketExecutor - readonly redact?: { - readonly headers?: ReadonlyArray - readonly query?: ReadonlyArray - readonly url?: (url: string) => string - } - readonly requestHeaders?: ReadonlyArray + readonly redactor?: Redactor readonly compareClientMessagesAsJson?: boolean } -const headersRecord = (headers: Headers.Headers) => +const headersRecord = (headers: Headers.Headers): Record => Object.fromEntries( - Object.entries(headers as Record) - .filter((entry): entry is [string, string] => typeof entry[1] === "string") - .toSorted(([a], [b]) => a.localeCompare(b)), + Object.entries(headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ) -const openSnapshot = ( - request: WebSocketRequest, - options: Pick, "redact" | "requestHeaders"> = {}, -) => ({ - url: redactUrl(request.url, options.redact?.query, options.redact?.url), - headers: redactHeaders( - headersRecord(request.headers), - options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS, - options.redact?.headers, - ), -}) - -const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body }) - -const frameText = (frame: WebSocketFrame) => { - if (frame.kind === "text") return frame.body - return new TextDecoder().decode(Buffer.from(frame.body, "base64")) -} - -const frameMessage = (frame: WebSocketFrame) => - frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) - -const receivedFrame = (message: string | Uint8Array): WebSocketFrame => +const encodeFrame = (message: string | Uint8Array): WebSocketFrame => typeof message === "string" - ? textFrame(message) + ? { kind: "text", body: message } : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } -const unsafeCassette = (name: string, findings: ReadonlyArray) => - new Error( - `Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings - .map((item) => `${item.path} (${item.reason})`) - .join(", ")}`, - ) +const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array => + frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) -const mismatch = (message: string, actual: unknown, expected: unknown) => - new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) +const decodeFrameText = (frame: WebSocketFrame) => + frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64")) const assertEqual = (message: string, actual: unknown, expected: unknown) => Effect.sync(() => { if (JSON.stringify(actual) === JSON.stringify(expected)) return - throw mismatch(message, actual, expected) + throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) }) const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) @@ -94,7 +63,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin return Effect.sync(() => { throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`) }) - const expectedText = frameText(expected) + const expectedText = decodeFrameText(expected) if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText) return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText)) } @@ -103,7 +72,18 @@ export const makeWebSocketExecutor = ( options: WebSocketRecordReplayOptions, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested + const redactor = options.redactor ?? defaults() + const openSnapshot = (request: WebSocketRequest) => { + const redacted = redactor.request({ + method: "GET", + url: request.url, + headers: headersRecord(request.headers), + body: "", + }) + return { url: redacted.url, headers: redacted.headers } + } if (mode === "passthrough") return options.live @@ -118,21 +98,21 @@ export const makeWebSocketExecutor = ( const closeOnce = Effect.gen(function* () { if (yield* Ref.getAndSet(closed, true)) return yield* connection.close - const result = yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request, options), client, server }, - options.metadata, - ) - .pipe(Effect.orDie) - if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings)) + yield* appendOrFail( + options.cassette, + options.name, + { transport: "websocket", open: openSnapshot(request), client, server }, + options.metadata, + ).pipe(Effect.orDie) }) return { - sendText: (message: string) => - connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))), + sendText: (message) => + connection + .sendText(message) + .pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))), messages: connection.messages.pipe( Stream.map((message) => { - server.push(receivedFrame(message)) + server.push(encodeFrame(message)) return message }), ), @@ -142,44 +122,20 @@ export const makeWebSocketExecutor = ( } } - const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray } | undefined>( - undefined, - ) - const cursor = yield* Ref.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const input = yield* Ref.get(replay) - if (!input) return - yield* assertEqual( - `Unused recorded WebSocket interactions in ${options.name}`, - yield* Ref.get(cursor), - input.interactions.length, - ) - }), - ) - - const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () { - const cached = yield* Ref.get(replay) - if (cached) return cached - const input = { - interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)), - } - yield* Ref.set(replay, input) - return input - }) + const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) return { - open: (request) => { - return Effect.gen(function* () { - const input = yield* loadReplay() - const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) - const interaction = input.interactions[index] + open: (request) => + Effect.gen(function* () { + const interactions = yield* replay.load.pipe(Effect.orDie) + const index = yield* replay.cursor + const interaction = interactions[index] if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`)) - yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open) + yield* replay.advance + yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open) const messageIndex = yield* Ref.make(0) return { - sendText: (message: string) => + sendText: (message) => Effect.gen(function* () { const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1) yield* compareClientMessage( @@ -189,7 +145,7 @@ export const makeWebSocketExecutor = ( options.compareClientMessagesAsJson === true, ) }), - messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)), + messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)), close: Effect.gen(function* () { yield* assertEqual( `WebSocket client frame count for interaction ${index + 1}`, @@ -198,7 +154,6 @@ export const makeWebSocketExecutor = ( ) }), } - }) - }, + }), } }) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/http-recorder/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 676422e6a4..7613563fd0 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -6,7 +6,16 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { HttpRecorder } from "../src" -import { redactedErrorRequest } from "../src/diff" +import { redactedErrorRequest } from "../src/effect" +import type { Interaction } from "../src/schema" + +const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)), + ) const post = (url: string, body: object) => Effect.gen(function* () { @@ -33,7 +42,7 @@ const runRecorder = (effect: Effect.Effect { test("detects secret-looking values without returning the secret", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, interactions: [ { @@ -136,7 +145,7 @@ describe("http-recorder", () => { test("detects secret-looking values inside metadata", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, metadata: { token: "sk-123456789012345678901234" }, interactions: [], @@ -144,60 +153,42 @@ describe("http-recorder", () => { ).toEqual([{ path: "metadata.token", reason: "API key" }]) }) - test("formats websocket cassettes with shared metadata", () => { - const cassette = HttpRecorder.cassetteFor( - "websocket/basic", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - { provider: "openai" }, - ) + test("replays websocket interactions seeded into the in-memory cassette adapter", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/replay", + cassette, + compareClientMessagesAsJson: true, + live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + const messages: Array = [] + yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) + yield* connection.close - expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) - expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette) - }) - - test("replays websocket interactions from the shared cassette service", async () => { - await runRecorder( - Effect.gen(function* () { - const cassette = yield* HttpRecorder.Cassette.Service - yield* cassette.write( - "websocket/replay", - HttpRecorder.cassetteFor( - "websocket/replay", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - undefined, + expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) + }).pipe( + Effect.provide( + HttpRecorder.Cassette.memory({ + "websocket/replay": [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + }), ), - ) - const executor = yield* HttpRecorder.makeWebSocketExecutor({ - name: "websocket/replay", - cassette, - compareClientMessagesAsJson: true, - live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, - }) - const connection = yield* executor.open({ - url: "wss://example.test/realtime", - headers: Headers.fromInput({ "content-type": "application/json" }), - }) - yield* connection.sendText(JSON.stringify({ type: "response.create" })) - const messages: Array = [] - yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) - yield* connection.close - - expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) - }), + ), + ), ) }) @@ -227,17 +218,14 @@ describe("http-recorder", () => { yield* connection.messages.pipe(Stream.runDrain) yield* connection.close - expect(yield* cassette.read("websocket/record")).toMatchObject({ - metadata: { name: "websocket/record", provider: "test" }, - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - }) + expect(yield* cassette.read("websocket/record")).toMatchObject([ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ]) }), ) }) @@ -300,6 +288,49 @@ describe("http-recorder", () => { ) }) + test("auto mode 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, mode: "auto" }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("auto mode 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.cassetteLayer("missing-cassette", { directory, mode: "auto" })), + ), + ), + ) + 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 closest redacted request differences", async () => { await run( Effect.gen(function* () { diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index 2c28d3ce74..5b84b152a4 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make({ stream: { event: Schema.String, initial: () => undefined, - step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const), + step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), onHalt: () => [{ type: "request-finish", reason: "stop" }], }, }) diff --git a/packages/llm/package.json b/packages/llm/package.json index 65d8c39d16..efaa9b57e0 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.22", + "version": "7.3.40", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index ff2239c0d7..a426807c02 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type CacheHint, type FinishReason, - type LLMEvent, type LLMRequest, type ProviderMetadata, type ToolCallPart, @@ -16,6 +16,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import * as Cache from "./utils/cache" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -25,7 +26,10 @@ export const PATH = "/messages" // ============================================================================= // Request Body Schema // ============================================================================= -const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") }) +const AnthropicCacheControl = Schema.Struct({ + type: Schema.tag("ephemeral"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), +}) const AnthropicTextBlock = Schema.Struct({ type: Schema.tag("text"), @@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest // ============================================================================= // Request Lowering // ============================================================================= -const cacheControl = (cache: CacheHint | undefined) => - cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined +// Anthropic accepts at most 4 explicit cache_control breakpoints per request, +// across `tools`, `system`, and `messages`. Beyond the cap the API returns a +// 400 — so the lowering layer counts emitted markers and silently drops any +// that exceed it. +const ANTHROPIC_BREAKPOINT_CAP = 4 + +const EPHEMERAL_5M = { type: "ephemeral" as const } +const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } + +const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} const anthropicMetadata = (metadata: Record): ProviderMetadata => ({ anthropic: metadata }) @@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema, + cache_control: cacheControl(breakpoints, tool.cache), }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock }) -const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) { +const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: Cache.Breakpoints, +) { const messages: AnthropicMessage[] = [] for (const message of request.messages) { @@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["text"])) return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) - content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) } messages.push({ role: "user", content }) continue @@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re const content: AnthropicAssistantBlock[] = [] for (const part of message.content) { if (part.type === "text") { - content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) continue } if (part.type === "reasoning") { @@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re tool_use_id: part.id, content: ProviderShared.toolResultText(part), is_error: part.result.type === "error" ? true : undefined, + cache_control: cacheControl(breakpoints, part.cache), }) } messages.push({ role: "user", content }) @@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + // Allocate the 4-breakpoint budget in invalidation order: tools → system → + // messages. Tools live highest in the cache hierarchy, so when callers + // over-mark we keep their tool hints and shed the message-tail ones first. + const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) + const tools = + request.tools.length === 0 || request.toolChoice?.type === "none" + ? undefined + : request.tools.map((tool) => lowerTool(breakpoints, tool)) + const system = + request.system.length === 0 + ? undefined + : request.system.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(breakpoints, part.cache), + })) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, + ) + } return { model: request.model.id, - system: - request.system.length === 0 - ? undefined - : request.system.map((part) => ({ - type: "text" as const, - text: part.text, - cache_control: cacheControl(part.cache), - })), - messages: yield* lowerMessages(request), - tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool), + system, + messages, + tools, tool_choice: toolChoice, stream: true as const, max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, @@ -415,14 +455,13 @@ const serverToolResultEvent = (block: NonNullable).type) : "" const isError = errorPayload.endsWith("_tool_result_error") - return { - type: "tool-result", + return LLMEvent.toolResult({ id: block.tool_use_id ?? "", name: SERVER_TOOL_RESULT_NAMES[block.type], result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, providerExecuted: true, providerMetadata: anthropicMetadata({ blockType: block.type }), - } + }) } type StepResult = readonly [ParserState, ReadonlyArray] @@ -453,18 +492,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes } if (block.type === "text" && block.text) { - return [state, [{ type: "text-delta", text: block.text }]] + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] } if (block.type === "thinking" && block.thinking) { return [ state, [ - { - type: "reasoning-delta", + LLMEvent.reasoningDelta({ + id: `reasoning-${event.index ?? 0}`, text: block.thinking, - ...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}), - }, + }), ], ] } @@ -480,17 +518,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f const delta = event.delta if (delta?.type === "text_delta" && delta.text) { - return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult } if (delta?.type === "thinking_delta" && delta.thinking) { - return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult + return [ + state, + [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + ] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { return [ state, - [{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }], + [ + LLMEvent.reasoningEnd({ + id: `reasoning-${event.index ?? 0}`, + providerMetadata: anthropicMetadata({ signature: delta.signature }), + }), + ], ] satisfies StepResult } @@ -524,21 +570,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = return [ { ...state, usage }, [ - { - type: "request-finish", + LLMEvent.requestFinish({ reason: mapFinishReason(event.delta?.stop_reason), usage, - ...(event.delta?.stop_sequence - ? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) } - : {}), - }, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }), ], ] } const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ state, - [{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }], + [LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })], ] const step = (state: ParserState, event: AnthropicEvent) => { diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 09176104df..e2ba1ff3be 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type CacheHint, type FinishReason, - type LLMEvent, type LLMRequest, type ToolCallPart, type ToolDefinition, @@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) type BedrockSystemBlock = Schema.Schema.Type -const BedrockTool = Schema.Struct({ +const BedrockToolSpec = Schema.Struct({ toolSpec: Schema.Struct({ name: Schema.String, description: Schema.String, @@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({ }), }), }) +type BedrockToolSpec = Schema.Schema.Type + +const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock]) type BedrockTool = Schema.Schema.Type const BedrockToolChoice = Schema.Union([ @@ -214,7 +217,7 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition): BedrockTool => ({ +const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ toolSpec: { name: tool.name, description: tool.description, @@ -222,11 +225,22 @@ const lowerTool = (tool: ToolDefinition): BedrockTool => ({ }, }) +const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { + const result: BedrockTool[] = [] + for (const tool of tools) { + result.push(lowerToolSpec(tool)) + const cachePoint = BedrockCache.block(breakpoints, tool.cache) + if (cachePoint) result.push(cachePoint) + } + return result +} + const textWithCache = ( + breakpoints: BedrockCache.Breakpoints, text: string, cache: CacheHint | undefined, ): Array => { - const cachePoint = BedrockCache.block(cache) + const cachePoint = BedrockCache.block(breakpoints, cache) return cachePoint ? [{ text }, cachePoint] : [{ text }] } @@ -257,7 +271,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({ }, }) -const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) { +const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: BedrockCache.Breakpoints, +) { const messages: BedrockMessage[] = [] for (const message of request.messages) { @@ -267,7 +284,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ if (!ProviderShared.supportsContent(part, ["text", "media"])) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) if (part.type === "text") { - content.push(...textWithCache(part.text, part.cache)) + content.push(...textWithCache(breakpoints, part.text, part.cache)) continue } if (part.type === "media") { @@ -289,7 +306,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ "tool-call", ]) if (part.type === "text") { - content.push(...textWithCache(part.text, part.cache)) + content.push(...textWithCache(breakpoints, part.text, part.cache)) continue } if (part.type === "reasoning") { @@ -309,11 +326,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ continue } - const content: BedrockToolResultBlock[] = [] + const content: BedrockUserBlock[] = [] for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["tool-result"])) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) content.push(lowerToolResult(part)) + const cachePoint = BedrockCache.block(breakpoints, part.cache) + if (cachePoint) content.push(cachePoint) } messages.push({ role: "user", content }) } @@ -323,16 +342,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ // System prompts share the cache-point convention: emit the text block, then // optionally a positional `cachePoint` marker. -const lowerSystem = (system: ReadonlyArray): BedrockSystemBlock[] => - system.flatMap((part) => textWithCache(part.text, part.cache)) +const lowerSystem = ( + breakpoints: BedrockCache.Breakpoints, + system: ReadonlyArray, +): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache)) const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in + // tools → system → messages order to favour the highest-impact prefixes. + const breakpoints = BedrockCache.breakpoints() + const toolConfig = + request.tools.length > 0 && request.toolChoice?.type !== "none" + ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + : undefined + const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, + ) + } return { modelId: request.model.id, - messages: yield* lowerMessages(request), - system: request.system.length === 0 ? undefined : lowerSystem(request.system), + messages, + system, inferenceConfig: generation?.maxTokens === undefined && generation?.temperature === undefined && @@ -345,10 +380,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: topP: generation?.topP, stopSequences: generation?.stop, }, - toolConfig: - request.tools.length > 0 && request.toolChoice?.type !== "none" - ? { tools: request.tools.map(lowerTool), toolChoice } - : undefined, + toolConfig, } }) @@ -400,13 +432,26 @@ const step = (state: ParserState, event: BedrockEvent) => } if (event.contentBlockDelta?.delta?.text) { - return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const + return [ + state, + [ + LLMEvent.textDelta({ + id: `text-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.text, + }), + ], + ] as const } if (event.contentBlockDelta?.delta?.reasoningContent?.text) { return [ state, - [{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }], + [ + LLMEvent.reasoningDelta({ + id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.reasoningContent.text, + }), + ], ] as const } @@ -449,16 +494,13 @@ const step = (state: ParserState, event: BedrockEvent) => event.modelStreamErrorException?.message ?? event.serviceUnavailableException?.message ?? "Bedrock Converse stream error" - return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const + return [state, [LLMEvent.providerError({ message, retryable: true })]] as const } if (event.validationException || event.throttlingException) { const message = event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" - return [ - state, - [{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }], - ] as const + return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const } return [state, []] as const @@ -468,7 +510,7 @@ const framing = BedrockEventStream.framing(ADAPTER) const onHalt = (state: ParserState): ReadonlyArray => state.pendingFinish - ? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }] + ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] : [] // ============================================================================= diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 0d2bdc8e14..140da521a5 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type MediaPart, type TextPart, @@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean const finish = (state: ParserState): ReadonlyArray => state.finishReason || state.usage - ? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }] + ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] : [] const step = (state: ParserState, event: GeminiEvent) => { @@ -332,14 +332,18 @@ const step = (state: ParserState, event: GeminiEvent) => { for (const part of candidate.content.parts) { if ("text" in part && part.text.length > 0) { - events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text }) + events.push( + part.thought + ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) + : LLMEvent.textDelta({ id: "text-0", text: part.text }), + ) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` - events.push({ type: "tool-call", id, name: part.functionCall.name, input }) + events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) hasToolCalls = true } } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 974e22950d..5d42c0a4e9 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -6,9 +6,9 @@ import { Framing } from "../route/framing" import { HttpTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type TextPart, type ToolCallPart, @@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools - if (delta?.content) events.push({ type: "text-delta", text: delta.content }) + if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( @@ -348,10 +348,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const finishEvents = (state: ParserState): ReadonlyArray => { const hasToolCalls = state.toolCallEvents.length > 0 const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason - return [ - ...state.toolCallEvents, - ...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray) : []), - ] + return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] } // ============================================================================= diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 780ed31bfc..14dc32130c 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -6,9 +6,9 @@ import { Framing } from "../route/framing" import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type ProviderMetadata, type TextPart, @@ -348,22 +348,20 @@ const hostedToolEvents = ( const tool = HOSTED_TOOLS[item.type] const providerMetadata = openaiMetadata({ itemId: item.id }) return [ - { - type: "tool-call", + LLMEvent.toolCall({ id: item.id, name: tool.name, input: tool.input(item), providerExecuted: true, providerMetadata, - }, - { - type: "tool-result", + }), + LLMEvent.toolResult({ id: item.id, name: tool.name, result: hostedToolResult(item), providerExecuted: true, providerMetadata, - }, + }), ] } @@ -379,17 +377,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] - return [ - state, - [ - { - type: "text-delta", - id: event.item_id, - text: event.delta, - ...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}), - }, - ], - ] + return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] } const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { @@ -458,30 +446,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, [ - { - type: "request-finish", + LLMEvent.requestFinish({ reason: mapFinishReason(event, state.hasFunctionCall), usage: mapUsage(event.response?.usage), - ...(event.response?.id || event.response?.service_tier - ? { - providerMetadata: openaiMetadata({ + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ responseId: event.response.id, serviceTier: event.response.service_tier, - }), - } - : {}), - }, + }) + : undefined, + }), ], ] const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }], + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })], ] const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }], + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })], ] const step = (state: ParserState, event: OpenAIResponsesEvent) => { diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/llm/src/protocols/utils/bedrock-cache.ts index ca6e52cd11..fab4d07b5c 100644 --- a/packages/llm/src/protocols/utils/bedrock-cache.ts +++ b/packages/llm/src/protocols/utils/bedrock-cache.ts @@ -1,20 +1,37 @@ import { Schema } from "effect" import type { CacheHint } from "../../schema" +import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache" // Bedrock cache markers are positional: emit a `cachePoint` block immediately -// after the content the caller wants treated as a cacheable prefix. +// after the content the caller wants treated as a cacheable prefix. Bedrock +// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic. export const CachePointBlock = Schema.Struct({ - cachePoint: Schema.Struct({ type: Schema.tag("default") }), + cachePoint: Schema.Struct({ + type: Schema.tag("default"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), + }), }) export type CachePointBlock = Schema.Schema.Type -// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map -// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape. -const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } } +// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages +// API. Callers pass a shared counter through every `block()` call site so the +// budget is respected across `system`, `messages`, and `tools`. +export const BEDROCK_BREAKPOINT_CAP = 4 -export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => { +export type { Breakpoints } from "./cache" +export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP) + +const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } } +const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } } + +export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => { if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined - return DEFAULT + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M } export * as BedrockCache from "./bedrock-cache" diff --git a/packages/llm/src/protocols/utils/cache.ts b/packages/llm/src/protocols/utils/cache.ts new file mode 100644 index 0000000000..dd3e213e0e --- /dev/null +++ b/packages/llm/src/protocols/utils/cache.ts @@ -0,0 +1,16 @@ +// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock +// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h` +// TTL buckets, so the counter and TTL mapping live here. + +export interface Breakpoints { + remaining: number + dropped: number +} + +export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 }) + +// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the +// provider default 5m). Anthropic & Bedrock both treat anything shorter than +// an hour as 5m. +export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined => + ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index e6ac5fefd0..aa9c70f017 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -49,34 +49,24 @@ const withoutTool = (tools: State, key: K): State => return next } -const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({ - type: "tool-input-delta", - id: tool.id, - name: tool.name, - text, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), -}) +const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => + LLMEvent.toolInputDelta({ + id: tool.id, + name: tool.name, + text, + }) const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( Effect.map( (input): ToolCall => - tool.providerExecuted - ? { - type: "tool-call", - id: tool.id, - name: tool.name, - input, - providerExecuted: true, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), - } - : { - type: "tool-call", - id: tool.id, - name: tool.name, - input, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), - }, + LLMEvent.toolCall({ + id: tool.id, + name: tool.name, + input, + providerExecuted: tool.providerExecuted ? true : undefined, + providerMetadata: tool.providerMetadata, + }), ), ) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 2fa69370f4..d0befe246e 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" import { ModelRef } from "./options" import { ToolResultValue } from "./messages" @@ -14,60 +14,87 @@ export class Usage extends Schema.Class("LLM.Usage")({ }) {} export const RequestStart = Schema.Struct({ - type: Schema.Literal("request-start"), - id: Schema.String, + type: Schema.tag("request-start"), + id: ResponseID, model: ModelRef, }).annotate({ identifier: "LLM.Event.RequestStart" }) export type RequestStart = Schema.Schema.Type export const StepStart = Schema.Struct({ - type: Schema.Literal("step-start"), + type: Schema.tag("step-start"), index: Schema.Number, }).annotate({ identifier: "LLM.Event.StepStart" }) export type StepStart = Schema.Schema.Type export const TextStart = Schema.Struct({ - type: Schema.Literal("text-start"), - id: Schema.String, + type: Schema.tag("text-start"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextStart" }) export type TextStart = Schema.Schema.Type export const TextDelta = Schema.Struct({ - type: Schema.Literal("text-delta"), - id: Schema.optional(Schema.String), + type: Schema.tag("text-delta"), + id: ContentBlockID, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextDelta" }) export type TextDelta = Schema.Schema.Type export const TextEnd = Schema.Struct({ - type: Schema.Literal("text-end"), - id: Schema.String, + type: Schema.tag("text-end"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextEnd" }) export type TextEnd = Schema.Schema.Type -export const ReasoningDelta = Schema.Struct({ - type: Schema.Literal("reasoning-delta"), - id: Schema.optional(Schema.String), - text: Schema.String, +export const ReasoningStart = Schema.Struct({ + type: Schema.tag("reasoning-start"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningStart" }) +export type ReasoningStart = Schema.Schema.Type + +export const ReasoningDelta = Schema.Struct({ + type: Schema.tag("reasoning-delta"), + id: ContentBlockID, + text: Schema.String, }).annotate({ identifier: "LLM.Event.ReasoningDelta" }) export type ReasoningDelta = Schema.Schema.Type +export const ReasoningEnd = Schema.Struct({ + type: Schema.tag("reasoning-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) +export type ReasoningEnd = Schema.Schema.Type + +export const ToolInputStart = Schema.Struct({ + type: Schema.tag("tool-input-start"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputStart" }) +export type ToolInputStart = Schema.Schema.Type + export const ToolInputDelta = Schema.Struct({ - type: Schema.Literal("tool-input-delta"), - id: Schema.String, + type: Schema.tag("tool-input-delta"), + id: ToolCallID, name: Schema.String, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolInputDelta" }) export type ToolInputDelta = Schema.Schema.Type +export const ToolInputEnd = Schema.Struct({ + type: Schema.tag("tool-input-end"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) +export type ToolInputEnd = Schema.Schema.Type + export const ToolCall = Schema.Struct({ - type: Schema.Literal("tool-call"), - id: Schema.String, + type: Schema.tag("tool-call"), + id: ToolCallID, name: Schema.String, input: Schema.Unknown, providerExecuted: Schema.optional(Schema.Boolean), @@ -76,8 +103,8 @@ export const ToolCall = Schema.Struct({ export type ToolCall = Schema.Schema.Type export const ToolResult = Schema.Struct({ - type: Schema.Literal("tool-result"), - id: Schema.String, + type: Schema.tag("tool-result"), + id: ToolCallID, name: Schema.String, result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), @@ -86,8 +113,8 @@ export const ToolResult = Schema.Struct({ export type ToolResult = Schema.Schema.Type export const ToolError = Schema.Struct({ - type: Schema.Literal("tool-error"), - id: Schema.String, + type: Schema.tag("tool-error"), + id: ToolCallID, name: Schema.String, message: Schema.String, providerMetadata: Schema.optional(ProviderMetadata), @@ -95,7 +122,7 @@ export const ToolError = Schema.Struct({ export type ToolError = Schema.Schema.Type export const StepFinish = Schema.Struct({ - type: Schema.Literal("step-finish"), + type: Schema.tag("step-finish"), index: Schema.Number, reason: FinishReason, usage: Schema.optional(Usage), @@ -104,7 +131,7 @@ export const StepFinish = Schema.Struct({ export type StepFinish = Schema.Schema.Type export const RequestFinish = Schema.Struct({ - type: Schema.Literal("request-finish"), + type: Schema.tag("request-finish"), reason: FinishReason, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), @@ -112,7 +139,7 @@ export const RequestFinish = Schema.Struct({ export type RequestFinish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ - type: Schema.Literal("provider-error"), + type: Schema.tag("provider-error"), message: Schema.String, retryable: Schema.optional(Schema.Boolean), providerMetadata: Schema.optional(ProviderMetadata), @@ -125,8 +152,12 @@ const llmEventTagged = Schema.Union([ TextStart, TextDelta, TextEnd, + ReasoningStart, ReasoningDelta, + ReasoningEnd, + ToolInputStart, ToolInputDelta, + ToolInputEnd, ToolCall, ToolResult, ToolError, @@ -135,20 +166,52 @@ const llmEventTagged = Schema.Union([ ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) +type WithID = Omit & { readonly id: ID | string } + +const responseID = (value: ResponseID | string) => ResponseID.make(value) +const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) +const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) + /** * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of * `events.filter(LLMEvent.guards["tool-call"])`. */ export const LLMEvent = Object.assign(llmEventTagged, { + requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), + stepStart: StepStart.make, + textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), + textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), + textEnd: (input: WithID) => TextEnd.make({ ...input, id: contentBlockID(input.id) }), + reasoningStart: (input: WithID) => + ReasoningStart.make({ ...input, id: contentBlockID(input.id) }), + reasoningDelta: (input: WithID) => + ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }), + reasoningEnd: (input: WithID) => + ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }), + toolInputStart: (input: WithID) => + ToolInputStart.make({ ...input, id: toolCallID(input.id) }), + toolInputDelta: (input: WithID) => + ToolInputDelta.make({ ...input, id: toolCallID(input.id) }), + toolInputEnd: (input: WithID) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }), + toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), + toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), + toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), + stepFinish: StepFinish.make, + requestFinish: RequestFinish.make, + providerError: ProviderErrorEvent.make, is: { requestStart: llmEventTagged.guards["request-start"], stepStart: llmEventTagged.guards["step-start"], textStart: llmEventTagged.guards["text-start"], textDelta: llmEventTagged.guards["text-delta"], textEnd: llmEventTagged.guards["text-end"], + reasoningStart: llmEventTagged.guards["reasoning-start"], reasoningDelta: llmEventTagged.guards["reasoning-delta"], + reasoningEnd: llmEventTagged.guards["reasoning-end"], + toolInputStart: llmEventTagged.guards["tool-input-start"], toolInputDelta: llmEventTagged.guards["tool-input-delta"], + toolInputEnd: llmEventTagged.guards["tool-input-end"], toolCall: llmEventTagged.guards["tool-call"], toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 9261842770..ada133f0db 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) export type ProviderID = typeof ProviderID.Type +export const ResponseID = Schema.String +export type ResponseID = Schema.Schema.Type + +export const ContentBlockID = Schema.String +export type ContentBlockID = Schema.Schema.Type + +export const ToolCallID = Schema.String +export type ToolCallID = Schema.Schema.Type + export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const export const ReasoningEffort = Schema.Literals(ReasoningEfforts) export type ReasoningEffort = Schema.Schema.Type diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index 3daf00bbc0..cc6b89a2c7 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign( name: Schema.String, result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), + cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Content.ToolResult" }), @@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign( name: input.name, result: ToolResultValue.make(input.result, input.resultType), providerExecuted: input.providerExecuted, + cache: input.cache, metadata: input.metadata, providerMetadata: input.providerMetadata, }), @@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class("LLM.ToolDefini name: Schema.String, description: Schema.String, inputSchema: JsonSchema, + cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index 20e27379bd..c6e716d45e 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -4,7 +4,7 @@ import { type ContentPart, type FinishReason, type LLMError, - type LLMEvent, + LLMEvent, LLMRequest, Message, type ProviderMetadata, @@ -115,11 +115,19 @@ interface StepState { const accumulate = (state: StepState, event: LLMEvent) => { if (event.type === "text-delta") { - appendStreamingText(state, "text", event.text, event.providerMetadata) + appendStreamingText(state, "text", event.text, undefined) return } if (event.type === "reasoning-delta") { - appendStreamingText(state, "reasoning", event.text, event.providerMetadata) + appendStreamingText(state, "reasoning", event.text, undefined) + return + } + if (event.type === "reasoning-end") { + appendStreamingText(state, "reasoning", "", event.providerMetadata) + return + } + if (event.type === "text-end") { + appendStreamingText(state, "text", "", event.providerMetadata) return } if (event.type === "tool-call") { @@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => result.type === "error" ? [ - { type: "tool-error", id: call.id, name: call.name, message: String(result.value) }, - { type: "tool-result", id: call.id, name: call.name, result }, + LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), + LLMEvent.toolResult({ id: call.id, name: call.name, result }), ] - : [{ type: "tool-result", id: call.id, name: call.name, result }] + : [LLMEvent.toolResult({ id: call.id, name: call.name, result })] const followUpRequest = ( request: LLMRequest, diff --git a/packages/llm/sst-env.d.ts b/packages/llm/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/llm/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 191b8529c0..5ac8b9d818 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -50,7 +50,9 @@ const request = LLM.request({ }) const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => - event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text } + event.type === "finish" + ? { type: "request-finish", reason: event.reason } + : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ id: "fake", diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 9380e554bf..e9ef58afa8 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -126,7 +126,7 @@ describe("llm constructors", () => { expect( LLMResponse.text({ events: [ - { type: "text-delta", text: "hi" }, + { type: "text-delta", id: "text-0", text: "hi" }, { type: "request-finish", reason: "stop" }, ], }), diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts new file mode 100644 index 0000000000..b048d53ba0 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -0,0 +1,48 @@ +import { Redactor } from "@opencode-ai/http-recorder" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) + +// Two identical generations in a row. The first call writes the prefix into +// Anthropic's cache; the second should report a cache read against the same +// prefix. Cassette captures both interactions in order. +const cacheRequest = LLM.request({ + id: "recorded_anthropic_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages-cache", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, +}) + +describe("Anthropic Messages cache recorded", () => { + recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + // The first call may write the cache (cacheWriteInputTokens > 0) or it + // may be a fresh miss (both fields 0) depending on whether the prefix is + // already warm on Anthropic's side. The assertion that matters is that + // the SECOND call reports a non-zero cache read. + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts index a8d87c46ff..aa5b258d3d 100644 --- a/packages/llm/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -1,3 +1,4 @@ +import { Redactor } from "@opencode-ai/http-recorder" import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, LLMError } from "../../src" @@ -30,7 +31,7 @@ const recorded = recordedTests({ provider: "anthropic", protocol: "anthropic-messages", requires: ["ANTHROPIC_API_KEY"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, }) describe("Anthropic Messages sad-path recorded", () => { diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 263828a0ad..2f2b2a3e86 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => { cacheReadInputTokens: 1, totalTokens: 7, }) - expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({ + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, }) expect(response.events.at(-1)).toMatchObject({ @@ -374,4 +374,134 @@ describe("Anthropic Messages route", () => { expect(error.message).toContain("Anthropic Messages user messages only support text content for now") }), ) + + it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("emits cache_control on tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "lookup", + description: "lookup tool", + inputSchema: { type: "object", properties: {} }, + cache: new CacheHint({ type: "ephemeral" }), + }, + ], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ + id: "call_1", + name: "lookup", + result: { temp: 72 }, + cache: new CacheHint({ type: "ephemeral" }), + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "What's the weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("drops cache_control breakpoints past the 4-per-request cap", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache: hint }, + { type: "text", text: "b", cache: hint }, + { type: "text", text: "c", cache: hint }, + { type: "text", text: "d", cache: hint }, + { type: "text", text: "e", cache: hint }, + { type: "text", text: "f", cache: hint }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system + const marked = system.filter((part) => part.cache_control !== undefined) + expect(marked).toHaveLength(4) + expect(system[4]?.cache_control).toBeUndefined() + expect(system[5]?.cache_control).toBeUndefined() + }), + ) + + it.effect("spends breakpoint budget on tools before system before messages", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "t1", + description: "t1", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t2", + description: "t2", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t3", + description: "t3", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t4", + description: "t4", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + ], + system: [{ type: "text", text: "system-tail", cache: hint }], + messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])], + }), + ) + + const body = prepared.body as { + tools: Array<{ cache_control?: unknown }> + system: Array<{ cache_control?: unknown }> + messages: Array<{ content: Array<{ cache_control?: unknown }> }> + } + expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true) + expect(body.system[0]?.cache_control).toBeUndefined() + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + }), + ) }) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts new file mode 100644 index 0000000000..23dd697b9a --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +// Use a Claude model on Bedrock — Nova has automatic prefix caching that +// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second +// call wouldn't deterministically prove cache mapping works. Override with +// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere. +const model = BedrockConverse.model({ + id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0", + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, +}) + +const cacheRequest = LLM.request({ + id: "recorded_bedrock_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "bedrock-converse-cache", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], +}) + +describe("Bedrock Converse cache recorded", () => { + recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 28be714bdf..afadd89ac7 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -440,6 +440,77 @@ describe("Bedrock Converse route", () => { expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") }), ) + + it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [{ type: "text", text: "system", cache }], + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }], + }) + }), + ) + + it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }], + }, + messages: [ + { role: "user", content: [{ text: "What's the weather?" }] }, + { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] }, + { + role: "user", + content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("drops cachePoint markers past the 4-per-request cap", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache }, + { type: "text", text: "b", cache }, + { type: "text", text: "c", cache }, + { type: "text", text: "d", cache }, + { type: "text", text: "e", cache }, + { type: "text", text: "f", cache }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system + expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4) + }), + ) }) // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts new file mode 100644 index 0000000000..145728fdc6 --- /dev/null +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -0,0 +1,47 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Gemini.model({ + id: "gemini-2.5-flash", + apiKey: process.env.GEMINI_API_KEY ?? "fixture", +}) + +// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The +// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent` +// API is out-of-band and intentionally not wired up). This test exists to +// pin the usage-parsing path: `cachedContentTokenCount` should surface as +// `cacheReadInputTokens` on the second identical call. +const cacheRequest = LLM.request({ + id: "recorded_gemini_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "gemini-cache", + provider: "google", + protocol: "gemini", + requires: ["GEMINI_API_KEY"], +}) + +describe("Gemini cache recorded", () => { + recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + // Implicit caching is best-effort on Gemini's side; we assert the field + // is at least populated and non-negative. When re-recording, verify the + // cassette shows > 0 in the second response's usage. + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index a80ab740c3..9de4e0dc25 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -204,9 +204,9 @@ describe("Gemini route", () => { totalTokens: 7, }) expect(response.events).toEqual([ - { type: "reasoning-delta", text: "thinking" }, - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: "!" }, + { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, { type: "request-finish", reason: "stop", diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 0e1151b7af..3fa27c706e 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -1,3 +1,4 @@ +import { Redactor } from "@opencode-ai/http-recorder" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as Gemini from "../../src/protocols/gemini" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) => .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") const cloudflareOptions = { - redact: { url: redactCloudflareURL }, + redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }), } describeRecordedGoldenScenarios([ @@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([ prefix: "anthropic-messages", model: anthropicHaiku, requires: ["ANTHROPIC_API_KEY"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, scenarios: ["text", "tool-call"], }, { @@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([ model: anthropicOpus, requires: ["ANTHROPIC_API_KEY"], tags: ["flagship"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, scenarios: [{ id: "tool-loop", temperature: false }], }, { diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 0998401094..8b0dfc2894 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -225,8 +225,8 @@ describe("OpenAI Chat route", () => { expect(response.text).toBe("Hello!") expect(response.events).toEqual([ - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: "!" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, { type: "request-finish", reason: "stop", diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts new file mode 100644 index 0000000000..0ac3dfe2b9 --- /dev/null +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -0,0 +1,44 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = OpenAIResponses.model({ + id: "gpt-4.1-mini", + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) + +// OpenAI caches prefixes automatically once they cross the 1024-token threshold; +// `CacheHint` is a no-op for the wire body. The stable signal is the +// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard +// so cache hits are observable. +const cacheRequest = LLM.request({ + id: "recorded_openai_responses_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, + providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } }, +}) + +const recorded = recordedTests({ + prefix: "openai-responses-cache", + provider: "openai", + protocol: "openai-responses", + requires: ["OPENAI_API_KEY"], +}) + +describe("OpenAI Responses cache recorded", () => { + recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 30add06d83..5141b44cc2 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -336,8 +336,8 @@ describe("OpenAI Responses route", () => { expect(response.text).toBe("Hello!") expect(response.events).toEqual([ - { type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } }, - { type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } }, + { type: "text-delta", id: "msg_1", text: "Hello" }, + { type: "text-delta", id: "msg_1", text: "!" }, { type: "request-finish", reason: "stop", @@ -394,14 +394,12 @@ describe("OpenAI Responses route", () => { id: "call_1", name: "lookup", text: '{"query"', - providerMetadata: { openai: { itemId: "item_1" } }, }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', - providerMetadata: { openai: { itemId: "item_1" } }, }, { type: "tool-call", diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 3fb3e0b9a9..8a02bc3a0a 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -6,6 +6,18 @@ import { tool } from "../src/tool" export const weatherToolName = "get_weather" +// A deterministic system prompt long enough to clear every supported provider's +// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic +// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating +// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters +// only when re-recording with `RECORD=true`. +export const LARGE_CACHEABLE_SYSTEM = (() => { + const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " + // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely + // above every provider's threshold. + return sentence.repeat(250) +})() + export const weatherTool = LLM.toolDefinition({ name: weatherToolName, description: "Get current weather for a city.", diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 6514f13dad..62e51337d9 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) => ...metadata, } const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") - const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe( + const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( Layer.provide(NodeFileSystem.layer), ) const requestExecutor = RequestExecutor.layer.pipe( diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts index eeea9f1b78..b7ad380dad 100644 --- a/packages/llm/test/recorded-websocket.ts +++ b/packages/llm/test/recorded-websocket.ts @@ -1,14 +1,13 @@ -import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder" +import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder" import { Effect, Layer } from "effect" import { WebSocketExecutor } from "../src/route" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" const liveWebSocket = WebSocketExecutor.open -type Mode = "record" | "replay" | "passthrough" export const webSocketCassetteLayer = ( cassette: string, - input: { readonly metadata?: Record; readonly mode: Mode }, + input: { readonly metadata?: Record; readonly mode: RecordReplayMode }, ): Layer.Layer => Layer.effect( WebSocketExecutor.Service, diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 2a39b6c144..ec4131a46c 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,6 +9,13 @@ - **Output**: creates `migration/_/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). +## Development server + +- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result. +- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`. +- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`. +- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`. + # Module shape Do not use `export namespace Foo { ... }` for module organization. It is not diff --git a/packages/opencode/migration/20260511000411_data_migration_state/migration.sql b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql new file mode 100644 index 0000000000..ba36a7f078 --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE `data_migration` ( + `name` text PRIMARY KEY, + `time_completed` integer NOT NULL +); diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json new file mode 100644 index 0000000000..e84aa1a6a1 --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json @@ -0,0 +1,1490 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "fdfcccee-fb3a-481f-b801-b9835fa30d5d", + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c4ad3e1f58..12dd8772ae 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,10 +1,10 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.22", + "version": "7.3.40", "name": "@kilocode/cli", "type": "module", "license": "MIT", - "private": true, + "private": false, "scripts": { "typecheck": "tsgo --noEmit", "test": "bun run script/test-runner.ts", @@ -60,6 +60,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/node": "catalog:", "@types/npm-package-arg": "6.1.4", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", @@ -113,6 +114,7 @@ "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-telemetry": "workspace:*", "@kilocode/plugin": "workspace:*", + "@kilocode/plugin-atomic-chat": "workspace:*", "@kilocode/sdk": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", @@ -134,6 +136,8 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@secretlint/core": "10.2.2", + "@secretlint/secretlint-rule-preset-recommend": "10.2.2", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -191,10 +195,28 @@ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5" + "zod-to-json-schema": "3.24.5", + "@silvia-odwyer/photon-node": "0.3.4" }, "overrides": { "drizzle-orm": "catalog:" }, - "peerDependencies": {} + "peerDependencies": {}, + "keywords": [ + "cli", + "tui", + "terminal", + "ai", + "agent", + "assistant", + "coding-agent", + "kilo-code", + "kilo", + "opencode", + "ink", + "react", + "copilot", + "autocomplete", + "developer-tools" + ] } diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 55e4c7268d..255c09644f 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -105,13 +105,13 @@ Verification: Concrete first targets: -- `sessionID` -- `messageID` -- `partID` -- `permissionID` -- `ptyID` +- `[x]` `sessionID` +- `[x]` `messageID` +- `[x]` `partID` +- `[x]` `permissionID` +- `[x]` `ptyID` -Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. +- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern. Verification: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index bdf9fcf345..ae6ec08041 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -26,9 +26,7 @@ import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" - -type ReferenceEntry = NonNullable[string] -type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } +import { Reference } from "@/reference/reference" export const Info = Schema.Struct({ name: Schema.String, @@ -303,69 +301,70 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePath(value: string) { - if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) - return path.isAbsolute(value) - ? value - : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) - } - - function resolveReference(reference: ReferenceEntry): ResolvedReference { - if (typeof reference === "string") { - if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { - return { kind: "local", path: referencePath(reference) } - } - return { kind: "git", repository: reference } - } - if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } - return { kind: "git", repository: reference.repository, branch: reference.branch } - } - - function referencePrompt(name: string, reference: ResolvedReference) { + function referencePrompt(reference: Reference.Resolved) { if (reference.kind === "local") { return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Local directory: ${reference.path}`, - `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, + ].join("\n\n") + } + + if (reference.kind === "invalid") { + return [ + `You are configured reference @${reference.name}, but this reference is not usable yet.`, + `Configured repository: ${reference.repository}`, + `Problem: ${reference.message}`, + `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, ].join("\n\n") } return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a git repository.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Repository: ${reference.repository}`, ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`, + `Cached directory: ${reference.path}`, + `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, + `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, ].join("\n\n") } + function referenceDescription(reference: Reference.Resolved) { + if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` + if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` + return `Invalid Scout reference for repository ${reference.repository}` + } + if (Flag.KILO_EXPERIMENTAL_SCOUT) { - for (const [name, reference] of Object.entries(cfg.reference ?? {})) { - if (agents[name]) continue - const resolved = resolveReference(reference) - const localPath = resolved.kind === "local" ? resolved.path : undefined - agents[name] = { - name, - description: - resolved.kind === "local" - ? `Scout reference for local directory ${resolved.path}` - : `Scout reference for repository ${resolved.repository}`, + const resolvedReferences = Reference.resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + for (const resolved of resolvedReferences) { + if (agents[resolved.name]) continue + const localPath = resolved.kind === "invalid" ? undefined : resolved.path + agents[resolved.name] = { + name: resolved.name, + description: referenceDescription(resolved), permission: Permission.merge( agents.scout.permission, - Permission.fromConfig( - localPath + Permission.fromConfig({ + repo_clone: "deny", + ...(localPath ? { external_directory: { [localPath]: "allow", [path.join(localPath, "*")]: "allow", }, } - : {}, - ), + : {}), + }), ), - prompt: referencePrompt(name, resolved), - options: { reference }, + prompt: referencePrompt(resolved), + options: { reference: cfg.reference?.[resolved.name], resolved }, mode: "subagent", native: false, } diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index 54a86efa30..c7c947450d 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const file: string export default file } + +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 89c7b516e5..4b83302b83 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -93,7 +93,6 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", - "app.exit", "app.debug", "app.console", "app.heap_snapshot", @@ -648,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise }) { title: "Exit the app", slashName: "exit", slashAliases: ["quit", "q"], - enabled: () => { - const current = promptRef.current - if (!current?.focused) return true - return current.current.input === "" - }, run: () => exit(), category: "System", }, @@ -785,6 +779,17 @@ function App(props: { onSnapshot?: () => Promise }) { bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) + useBindings(() => ({ + enabled: () => { + const ok = command.matcher.get() + if (!ok) return false + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + })) + event.on(TuiEvent.CommandExecute.type, (evt) => { command.run(evt.properties.command) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 976f45b226..542d349702 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) { ...input.traits, ...computePromptTraits({ mode: store.mode, - disabled: !!props.disabled, autocompleteVisible: !!auto()?.visible, }), } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index a701396562..03b0580529 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell" export interface PromptTraitsInput { mode: PromptMode - disabled: boolean autocompleteVisible: boolean } @@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & { /** * Compute the textarea editor traits for the prompt. * - * `traits.suspend` gates the textarea's keybinding actions (backspace, - * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active - * editing mode — only `disabled` should suspend the textarea, otherwise - * users can type in shell mode but cannot delete or move the cursor. + * The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits + * only expose capture/status metadata so focus changes cannot unsuspend the + * keymap-managed editor mappings. */ export function computePromptTraits(input: PromptTraitsInput): PromptTraits { const capture = @@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits { : undefined return { capture, - suspend: input.disabled, status: input.mode === "shell" ? "SHELL" : undefined, owner: "opencode", role: "prompt", diff --git a/packages/opencode/src/cli/cmd/tui/context/path-format.tsx b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx new file mode 100644 index 0000000000..1c9f19c6c6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx @@ -0,0 +1,39 @@ +import path from "path" +import { createContext, useContext, type ParentProps } from "solid-js" +import { Global } from "@opencode-ai/core/global" + +const context = createContext<{ + path: () => string + format: (input?: string) => string +}>() + +export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { + return ( + props.path || process.cwd(), format: (input) => formatPath(input, props.path) }} + > + {props.children} + + ) +} + +export function usePathFormatter() { + const value = useContext(context) + if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") + return value +} + +function formatPath(input: string | undefined, base: string | undefined) { + if (!input) return "" + + const root = base || process.cwd() + const absolute = path.isAbsolute(input) ? input : path.resolve(root, input) + const relative = path.relative(root, absolute) + + if (!relative) return "." + if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative + if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) { + return absolute.replace(Global.Path.home, "~") + } + return absolute +} diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 379fa5afdf..289bb901d6 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -8,9 +8,9 @@ import { import { KeymapProvider, reactiveMatcherFromSignal, - useBindings, useKeymap, useKeymapSelector, + useBindings, } from "@opentui/keymap/solid" import type { Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" @@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +const KEY_ALIASES = { + enter: "return", + esc: "escape", +} as const + +function expandKeyAliases(input: string) { + const result = Object.entries(KEY_ALIASES).reduce( + (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`), + input, + ) + if (result === input) return + return result +} + +function registerKeyAliases(keymap: OpenTuiKeymap) { + return keymap.appendBindingExpander((ctx) => { + const key = expandKeyAliases(ctx.input) + if (!key) return + return [{ key, displays: ctx.displays }] + }) +} + const inputCommands = [ "input.move.left", "input.move.right", @@ -98,8 +120,13 @@ export function formatKeyBindings( return formatCommandBindingsExtra(bindings, formatOptions(config)) } -export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { +export function registerOpencodeKeymap( + keymap: OpenTuiKeymap, + renderer: CliRenderer, + config: Pick, +) { const offCommaBindings = addons.registerCommaBindings(keymap) + const offAliasExpander = registerKeyAliases(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offLeader = addons.registerTimedLeader(keymap, { trigger: config.keybinds.get(LEADER_TOKEN), @@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende }) const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) - const offInputCommands = addons.registerEditBufferCommands(keymap, renderer) - const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer) - const offInputBindings = keymap.registerLayer({ + const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { enabled: () => renderer.currentFocusedEditor !== null, bindings: config.keybinds.gather("input", inputCommands), }) return () => { offInputBindings() - offInputSuspension() - offInputCommands() offBackspace() offEscape() offLeader() + offAliasExpander() offBaseLayout() offCommaBindings() } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 61e98d326a..469c693b86 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { Global } from "@opencode-ai/core/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut } from "../../keymap" +import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" addDefaultParsers(parsers.parsers) @@ -1078,199 +1078,201 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - - - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - - {(message, index) => ( - - - {(function () { - const command = useCommandPalette() - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.run("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - - )} - - - - 0}> - - - 0}> - - - - - - - - { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - - - - - - - - - - - - + + + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} > - + + + {(message, index) => ( + + + {(function () { + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.run("session.redo") + } + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {revert()!.reverted.length} message reverted + + {redoShortcut()} or /redo to restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + + + + ) + })()} + + = revert()!.messageID}> + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + + + + + + )} + + + + 0}> + + + 0}> + + + + + + + + { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + - - - - - + + + + + + + + + + + + + + + + + + ) } @@ -1827,7 +1829,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const sync = useSync() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) @@ -1841,18 +1843,7 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - - const base = sync.path.directory - if (!base) return undefined - - const absolute = path.resolve(base, workdir) - if (absolute === base) return undefined - - const home = Global.Path.home - if (!home) return absolute - - const match = absolute === home || absolute.startsWith(home + path.sep) - return match ? absolute.replace(home, "~") : absolute + return pathFormatter.format(workdir) }) const title = createMemo(() => { @@ -1894,6 +1885,7 @@ function Shell(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const code = createMemo(() => { if (!props.input.content) return "" return props.input.content @@ -1902,7 +1894,7 @@ function Write(props: ToolProps) { return ( - + ) { - Write {normalizePath(props.input.filePath!)} + Write {pathFormatter.format(props.input.filePath)} @@ -1925,9 +1917,10 @@ function Write(props: ToolProps) { } function Glob(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + Glob "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) @@ -1937,6 +1930,7 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] @@ -1954,13 +1948,13 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} {(filepath) => ( - ↳ Loaded {normalizePath(filepath)} + ↳ Loaded {pathFormatter.format(filepath)} )} @@ -1970,9 +1964,10 @@ function Read(props: ToolProps) { } function Grep(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + Grep "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) @@ -2071,6 +2066,7 @@ function Task(props: ToolProps) { function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const view = createMemo(() => { const diffStyle = ctx.tui.diff_style @@ -2086,7 +2082,7 @@ function Edit(props: ToolProps) { return ( - + ) { - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} @@ -2123,6 +2119,7 @@ function Edit(props: ToolProps) { function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const files = createMemo(() => props.metadata.files ?? []) @@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath return "← Patched " + file.relativePath } @@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record[] ) } -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use absolute - return absolute -} - function input(input: Record, omit?: string[]): string { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 1d6b51c9bc..526dd30f3b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -11,34 +11,16 @@ import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" -import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useBindings, useCommandShortcut } from "../../keymap" +import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use ~ or absolute - if (home && (absolute === home || absolute.startsWith(home + path.sep))) { - return absolute.replace(home, "~") - } - return absolute -} - function filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) @@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) + const pathFormatter = usePathFormatter() const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filepath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Edit ${normalizePath(filepath)}`, + title: `Edit ${pathFormatter.format(filepath)}`, body: , } } @@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filePath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Read ${normalizePath(filePath)}`, + title: `Read ${pathFormatter.format(filePath)}`, body: ( - {"Path: " + normalizePath(filePath)} + {"Path: " + pathFormatter.format(filePath)} ), @@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const dir = typeof raw === "string" ? raw : "" return { icon: "→", - title: `List ${normalizePath(dir)}`, + title: `List ${pathFormatter.format(dir)}`, body: ( - {"Path: " + normalizePath(dir)} + {"Path: " + pathFormatter.format(dir)} ), @@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived - const dir = normalizePath(raw) + const dir = pathFormatter.format(raw) const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") return { diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts index da94eb9ca2..dd37a67158 100644 --- a/packages/opencode/src/cli/cmd/tui/validate-session.ts +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -1,5 +1,8 @@ import { createKiloClient } from "@kilocode/sdk/v2" import { SessionID } from "@/session/schema" +import { Schema } from "effect" + +const decodeSessionID = Schema.decodeUnknownSync(SessionID) export async function validateSession(input: { url: string @@ -10,9 +13,11 @@ export async function validateSession(input: { }) { if (!input.sessionID) return - const result = SessionID.zod.safeParse(input.sessionID) - if (!result.success) { - throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + let sessionID: SessionID + try { + sessionID = decodeSessionID(input.sessionID) + } catch (error) { + throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }) } await createKiloClient({ @@ -20,5 +25,5 @@ export async function validateSession(input: { directory: input.directory, fetch: input.fetch, headers: input.headers, - }).session.get({ sessionID: result.data }, { throwOnError: true }) + }).session.get({ sessionID }, { throwOnError: true }) } diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts new file mode 100644 index 0000000000..7af429afde --- /dev/null +++ b/packages/opencode/src/config/attachment.ts @@ -0,0 +1,30 @@ +export * as ConfigAttachment from "./attachment" + +import { Schema } from "effect" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" + +export const Image = Schema.Struct({ + auto_resize: Schema.optional(Schema.Boolean).annotate({ + description: "Resize images before sending them to the model when they exceed configured limits (default: true)", + }), + max_width: Schema.optional(PositiveInt).annotate({ + description: "Maximum image width before resizing or rejecting the attachment (default: 2000)", + }), + max_height: Schema.optional(PositiveInt).annotate({ + description: "Maximum image height before resizing or rejecting the attachment (default: 2000)", + }), + max_base64_bytes: Schema.optional(PositiveInt).annotate({ + description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", + }), +}) + .annotate({ identifier: "ImageAttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Image = Schema.Schema.Type + +export const Info = Schema.Struct({ + image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), +}) + .annotate({ identifier: "AttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e89f28236a..dfa283e806 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context" import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" +import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" @@ -241,6 +242,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + attachment: Schema.optional(ConfigAttachment.Info).annotate({ + description: "Attachment processing configuration, including image size limits and resizing behavior", + }), enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index dd4c325490..1954543f4a 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,18 +1,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( - Schema.brand("WorkspaceID"), -) +const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f8a2590f48..7d72a83947 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -676,14 +676,12 @@ export const layer = Layer.effect( } if (input.workspaceID === null) { - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: null, - }, - }), - ) + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }) log.info("session warp complete", { workspaceID: input.workspaceID, diff --git a/packages/opencode/src/data-migration.sql.ts b/packages/opencode/src/data-migration.sql.ts new file mode 100644 index 0000000000..ba446b501c --- /dev/null +++ b/packages/opencode/src/data-migration.sql.ts @@ -0,0 +1,6 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" + +export const DataMigrationTable = sqliteTable("data_migration", { + name: text().primaryKey(), + time_completed: integer().notNull(), +}) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts new file mode 100644 index 0000000000..c3e5a9d2b0 --- /dev/null +++ b/packages/opencode/src/data-migration.ts @@ -0,0 +1,59 @@ +import { Context, Effect, Layer } from "effect" +import { Database } from "./storage/db" +import { DataMigrationTable } from "./data-migration.sql" +import * as Log from "@opencode-ai/core/util/log" +import { eq } from "drizzle-orm" + +export type Migration = { + name: string + run: Effect.Effect +} + +const log = Log.create({ service: "data-migration" }) + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/DataMigration") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const migrations: Migration[] = [] + + yield* Effect.gen(function* () { + if (migrations.length === 0) return + + // Migrations run in a background fiber, so they must be resumable until + // their completion row is written. + for (const migration of migrations) { + const completed = Database.use((db) => + db + .select({ name: DataMigrationTable.name }) + .from(DataMigrationTable) + .where(eq(DataMigrationTable.name, migration.name)) + .get(), + ) + if (completed) continue + + log.info("running data migration", { name: migration.name }) + yield* migration.run + Database.use((db) => + db + .insert(DataMigrationTable) + .values({ name: migration.name, time_completed: Date.now() }) + .onConflictDoNothing() + .run(), + ) + } + }).pipe( + Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.ignore, + Effect.forkScoped, + ) + return Service.of({}) + }), +) + +export const defaultLayer = layer + +export * as DataMigration from "./data-migration" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 76ed26d302..4c1637006c 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -43,6 +43,7 @@ import { Format } from "@/format" import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Reference } from "@/reference/reference" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" @@ -53,6 +54,7 @@ import { SessionShare } from "@/share/session" import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { DataMigration } from "@/data-migration" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -96,6 +98,7 @@ export const AppLayer = Layer.mergeAll( Format.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Reference.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, @@ -104,6 +107,7 @@ export const AppLayer = Layer.mergeAll( ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, + DataMigration.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6d9a6447a0..9e163cd6b8 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,4 +1,3 @@ -import z from "zod" import { randomBytes } from "crypto" const prefixes = { @@ -7,19 +6,12 @@ const prefixes = { message: "msg", permission: "per", question: "que", - user: "usr", part: "prt", pty: "pty", tool: "tool", workspace: "wrk", - entry: "ent", - account: "act", } as const -export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) -} - const LENGTH = 26 // State for monotonic ID generation diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 0000000000..2115e19198 --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,180 @@ +import { Config } from "@/config/config" +import type { MessageV2 } from "@/session/message-v2" +import * as Log from "@opencode-ai/core/util/log" +import { Context, Effect, Layer, Schema } from "effect" + +const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const AUTO_RESIZE = true +const JPEG_QUALITIES = [80, 85, 70, 55, 40] +const log = Log.create({ service: "image" }) + +export class PhotonUnavailableError extends Schema.TaggedErrorClass()( + "ImagePhotonUnavailableError", + {}, +) { + override get message() { + return "Photon image processor is unavailable" + } +} + +export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { + url: Schema.String, +}) { + override get message() { + return "Image URL must be a base64 data URL" + } +} + +export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { + override get message() { + return "Image could not be decoded" + } +} + +export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { + bytes: Schema.Number, + max: Schema.Number, + width: Schema.Number, + height: Schema.Number, + max_width: Schema.Number, + max_height: Schema.Number, +}) { + override get message() { + return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` + } +} + +export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError + +export interface Interface { + readonly normalize: (input: MessageV2.FilePart) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadPhoton = yield* Effect.cached( + Effect.promise(async () => { + try { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) + .default + // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + photonWasm + return await import("@silvia-odwyer/photon-node") + } catch { + return null + } + }), + ) + + const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { + const image = (yield* config.get()).attachment?.image + const info = { + autoResize: image?.auto_resize ?? AUTO_RESIZE, + maxWidth: image?.max_width ?? MAX_WIDTH, + maxHeight: image?.max_height ?? MAX_HEIGHT, + maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, + } + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) + return yield* new InvalidDataUrlError({ url: input.url }) + + const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + const photon = yield* loadPhoton + if (!photon) return yield* new PhotonUnavailableError() + + const decoded = yield* Effect.sync(() => { + try { + return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")) + } catch { + return undefined + } + }) + if (!decoded) return yield* new DecodeError() + + try { + const originalWidth = decoded.get_width() + const originalHeight = decoded.get_height() + if ( + originalWidth <= info.maxWidth && + originalHeight <= info.maxHeight && + Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes + ) + return input + if (!info.autoResize) + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + + const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) + for (const size of Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, [])) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + const candidate = [ + { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, + ...JPEG_QUALITIES.map((quality) => ({ + data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), + mime: "image/jpeg", + })), + ] + .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) + .find((item) => item.bytes <= info.maxBase64Bytes) + resized.free() + + if (candidate) { + log.info("using resized image", { + from_mime: input.mime, + to_mime: candidate.mime, + from: `${originalWidth}x${originalHeight}`, + to: `${size.width}x${size.height}`, + }) + return { + ...input, + mime: candidate.mime, + url: `data:${candidate.mime};base64,${candidate.data}`, + } + } + } + + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + } finally { + decoded.free() + } + }) + + return Service.of({ normalize }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export * as Image from "./image" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 725030935d..f7c6e2c5b7 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,12 +1,12 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", - Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }), + Schema.String.check(Schema.isStartsWith("per")), ) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index fb3e1bb32d..6103a9efb4 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" +import { Reference } from "@/reference/reference" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" @@ -29,6 +30,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service + const reference = yield* Reference.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -43,7 +45,7 @@ export const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( LSP.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, + Reference.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 0f1d6996df..fadb0457e7 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index c18eca3e23..1856c94bc7 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,13 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" -export class QuestionID extends Newtype()( - "QuestionID", - Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }), -) { +export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts new file mode 100644 index 0000000000..cc05fbee02 --- /dev/null +++ b/packages/opencode/src/reference/reference.ts @@ -0,0 +1,237 @@ +import path from "path" +import { Effect, Context, Layer, Scope } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { Git } from "@/git" +import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" +import { RepositoryCache } from "./repository-cache" + +type ReferenceEntry = NonNullable[string] + +export type Resolved = + | { + name: string + kind: "local" + path: string + } + | { + name: string + kind: "git" + repository: string + reference: RepositoryReference + path: string + branch?: string + } + | { + name: string + kind: "invalid" + repository: string + message: string + } + +type State = { + references: Resolved[] + materializeAll: Effect.Effect + materializeByPath: { path: string; run: Effect.Effect }[] +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (name: string) => Effect.Effect + readonly ensure: (target?: string) => Effect.Effect + readonly contains: (target?: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Reference") {} + +export function referencePath(input: { directory: string; worktree: string; value: string }) { + if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2)) + return path.isAbsolute(input.value) + ? input.value + : path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value) +} + +function resolveGit( + input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined }, +): Resolved { + const parsed = parseRepositoryReference(input.repository) + if (!parsed || parsed.protocol === "file:") { + return { + name: input.name, + kind: "invalid", + repository: input.repository, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + } + } + return { + name: input.name, + kind: "git", + repository: input.repository, + reference: parsed, + path: repositoryCachePath(parsed), + ...("branch" in input ? { branch: input.branch } : {}), + } +} + +function branchLabel(branch: string | undefined) { + return branch ?? "default branch" +} + +function normalizedTarget(target?: string) { + if (!target) return + return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target +} + +function containsReferencePath(referencePath: string, target: string) { + return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target) +} + +export function resolve(input: { + name: string + reference: ReferenceEntry + directory: string + worktree: string +}): Resolved { + if (typeof input.reference === "string") { + if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) } + } + return resolveGit({ name: input.name, repository: input.reference }) + } + + if ("path" in input.reference) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) } + } + + return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch }) +} + +export function resolveAll(input: { + references: NonNullable + directory: string + worktree: string +}) { + const seen = new Map() + return Object.entries(input.references).map(([name, reference]) => { + const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree }) + if (resolved.kind !== "git") return resolved + + const existing = seen.get(resolved.path) + if (!existing) { + seen.set(resolved.path, { name, branch: resolved.branch }) + return resolved + } + if (existing.branch === resolved.branch) return resolved + + return { + name, + kind: "invalid" as const, + repository: resolved.repository, + message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`, + } + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const scope = yield* Scope.Scope + + const state = yield* InstanceState.make( + Effect.fn("Reference.state")(function* (ctx) { + const cfg = yield* config.get() + const references = resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + const seenPath = new Set() + const gitReferences = references.filter((reference): reference is Extract => { + if (reference.kind !== "git") return false + if (seenPath.has(reference.path)) return false + seenPath.add(reference.path) + return true + }) + const materializeByPath = yield* Effect.forEach( + gitReferences, + Effect.fnUntraced(function* (reference) { + const run = yield* Effect.cached( + RepositoryCache.ensure( + { reference: reference.reference, branch: reference.branch, refresh: true }, + { fs, git }, + ).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + ), + ), + ) + return { path: reference.path, run } + }), + { concurrency: "unbounded" }, + ) + + const materializeAll = yield* Effect.cached( + Flag.KILO_EXPERIMENTAL_SCOUT + ? Effect.gen(function* () { + yield* Effect.forEach( + materializeByPath, + Effect.fnUntraced(function* (item) { + yield* item.run + }), + { concurrency: 4, discard: true }, + ) + }) + : Effect.void, + ) + + return { references, materializeAll, materializeByPath } + }), + ) + + return Service.of({ + init: Effect.fn("Reference.init")(function* () { + if (!Flag.KILO_EXPERIMENTAL_SCOUT) return + yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) + }), + list: Effect.fn("Reference.list")(function* () { + return yield* InstanceState.use(state, (s) => s.references) + }), + get: Effect.fn("Reference.get")(function* (name: string) { + return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) + }), + ensure: Effect.fn("Reference.ensure")(function* (target?: string) { + if (!Flag.KILO_EXPERIMENTAL_SCOUT) return + const full = normalizedTarget(target) + if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) + return yield* InstanceState.useEffect( + state, + (s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void, + ) + }), + contains: Effect.fn("Reference.contains")(function* (target?: string) { + if (!Flag.KILO_EXPERIMENTAL_SCOUT) return false + const full = normalizedTarget(target) + if (!full) return false + return yield* InstanceState.use(state, (s) => + s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)), + ) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) + +export * as Reference from "./reference" diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts new file mode 100644 index 0000000000..d31db8ab5f --- /dev/null +++ b/packages/opencode/src/reference/repository-cache.ts @@ -0,0 +1,147 @@ +import path from "path" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import { + repositoryCachePath, + sameRepositoryReference, + parseRepositoryReference, + validateRepositoryBranch, + type Reference as RepositoryReference, +} from "@/util/repository" + +export type Result = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( + input: { + reference: RepositoryReference + refresh?: boolean + branch?: string + }, + services: { + fs: AppFileSystem.Interface + git: Git.Interface + }, +) { + if (input.branch) validateRepositoryBranch(input.branch) + + const repository = input.reference.label + const remote = input.reference.remote + const localPath = repositoryCachePath(input.reference) + const cloneTarget = parseRepositoryReference(remote) ?? input.reference + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* services.fs.existsSafe(localPath) + const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: input.refresh, + branchMatches: input.branch ? currentBranch === input.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* services.git.run( + ["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`) + } + } + + if (status === "refreshed") { + const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`) + } + + if (input.branch) { + const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`, + ) + } + } + + const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: input.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`) + } + } + + const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* services.git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + repository, + host: input.reference.host, + remote, + localPath, + status, + head: headText, + branch, + } satisfies Result + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) +}) + +export * as RepositoryCache from "./repository-cache" diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index bdd917e4e8..4c6e46a455 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" +import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) + .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) + .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts index d5b10d1800..c780f5222c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -6,3 +6,7 @@ export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) + +export const QueryBooleanOpenApi = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 2328375b97..6aa87ee84e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -203,13 +203,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - yield* promptSvc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }) + yield* promptSvc + .command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return true }) @@ -258,20 +260,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { const instance = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID - return HttpServerResponse.stream( - Stream.fromEffect( - promptSvc - .prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }) - .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), - ).pipe( - Stream.map((message) => JSON.stringify(message)), - Stream.encodeText, - ), - { contentType: "application/json" }, - ) + const message = yield* promptSvc + .prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }) + .pipe( + Effect.provideService(InstanceRef, instance), + Effect.provideService(WorkspaceRef, workspace), + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { + contentType: "application/json", + }) }) const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: { @@ -297,7 +298,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + return yield* promptSvc + .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts new file mode 100644 index 0000000000..e7d661c5a8 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server" }) + +// Effect's Issue formatter recursively dumps the rejected `actual` value with +// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep +// 4xx responses small and avoid mirroring entire request payloads (which may +// contain secrets) into the response body and log file. +const REASON_LIMIT = 1024 +function truncateReason(reason: string) { + if (reason.length <= REASON_LIMIT) return reason + return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)` +} + +// Default Respondable returns an empty 400 body. Match the NamedError shape +// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`. +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", +) {} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + const reason = truncateReason(error.cause.message) + log.warn("schema rejection", { kind: error.kind, reason }) + return Effect.succeed( + HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 156ebf6834..460a2be7a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,5 +1,6 @@ import { OpenApi } from "effect/unstable/httpapi" import { OpenCodeHttpApi } from "./api" +import { QueryBooleanOpenApi } from "./groups/query" type OpenApiParameter = { name: string @@ -54,29 +55,23 @@ type OpenApiResponse = { // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas: Record = { "GET /experimental/session start": { type: "number" }, + "GET /experimental/session roots": QueryBooleanOpenApi, + "GET /experimental/session archived": QueryBooleanOpenApi, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /experimental/session cursor": { type: "number" }, "GET /experimental/session limit": { type: "number" }, "GET /session start": { type: "number" }, + "GET /session roots": QueryBooleanOpenApi, "GET /session limit": { type: "number" }, - "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /api/session limit": { type: "number" }, "GET /api/session start": { type: "number" }, + "GET /api/session roots": QueryBooleanOpenApi, "GET /api/session/{sessionID}/message limit": { type: "number" }, } -const PathParameterSchemas: Record = { - sessionID: { type: "string", pattern: "^ses.*" }, - messageID: { type: "string", pattern: "^msg.*" }, - partID: { type: "string", pattern: "^prt.*" }, - permissionID: { type: "string", pattern: "^per.*" }, - ptyID: { type: "string", pattern: "^pty.*" }, -} - const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", @@ -183,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) { if (!spec.components?.schemas) return spec.components.schemas.BadRequestError = { type: "object", - required: ["data", "errors", "success"], + required: ["name", "data"], properties: { - data: {}, - errors: { - type: "array", - items: { - type: "object", - additionalProperties: {}, + name: { type: "string", enum: ["BadRequest"] }, + data: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + kind: { + type: "string", + enum: ["Params", "Headers", "Query", "Body", "Payload"], + }, }, }, - success: { type: "boolean", enum: [false] }, }, } spec.components.schemas.NotFoundError = { @@ -486,7 +484,7 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | function normalizeParameter(param: OpenApiParameter, route: string) { if (!param.schema || typeof param.schema !== "object") return if (param.in === "path") { - param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) + param.schema = stripOptionalNull(param.schema) return } if (param.in === "query") { @@ -495,25 +493,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) { param.schema = override return } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], - } - return - } } param.schema = stripOptionalNull(param.schema) } -function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name] - if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } - if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } - return undefined -} - export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 495497ecb4..7ce21dfadb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" +import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware @@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe httpApiAuthLayer, workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, + schemaErrorLayer, ]), ) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 067d43da2e..0ceac9f1bb 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -4,7 +4,6 @@ import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" -import z from "zod" import { Token } from "@/util/token" import * as Log from "@opencode-ai/core/util/log" import { SessionProcessor } from "./processor" @@ -18,9 +17,10 @@ import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" -import { fn } from "@/util/fn" -import { EventV2 } from "@/v2/event" +import { serviceUse } from "@/effect/service-use" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" +import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "session.compaction" }) @@ -208,6 +208,8 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionCompaction") {} +export const use = serviceUse(Service) + export const layer: Layer.Layer< Service, never, @@ -218,6 +220,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionProcessor.Service | Provider.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -228,6 +231,7 @@ export const layer: Layer.Layer< const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service + const sync = yield* SyncEvent.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: MessageV2.Assistant["tokens"] @@ -566,12 +570,14 @@ export const layer: Layer.Layer< parts: [], }, ) - EventV2.run(SessionEvent.Compaction.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - text: summary ?? "", - include: selected.tail_start_id, - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) } return result @@ -600,11 +606,13 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) - EventV2.run(SessionEvent.Compaction.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) + } }) return Service.of({ @@ -625,6 +633,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) @@ -638,15 +647,4 @@ export async function prune(input: { sessionID: SessionID }) { return runPromise((svc) => svc.prune(input)) } -export const create = fn( - z.object({ - sessionID: SessionID.zod, - agent: z.string(), - model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.create(input)), -) - export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6e84db16e2..fb0482f080 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot" import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" +import { Image } from "@/image/image" import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" @@ -20,10 +21,11 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" +import { Flag } from "@opencode-ai/core/flag/flag" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -92,8 +94,10 @@ export const layer: Layer.Layer< | LLM.Service | Permission.Service | Plugin.Service + | Image.Service | SessionSummary.Service | SessionStatus.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -108,6 +112,8 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const image = yield* Image.Service + const sync = yield* SyncEvent.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -226,11 +232,13 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Started.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -259,12 +267,14 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Ended.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - text: ctx.reasoningMap[value.id].text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -278,12 +288,14 @@ export const layer: Layer.Layer< throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Started.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - name: value.toolName, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -307,12 +319,14 @@ export const layer: Layer.Layer< case "tool-input-end": { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - text: "", - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } return } @@ -322,17 +336,19 @@ export const layer: Layer.Layer< } const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Called.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - tool: value.toolName, - input: value.input, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -377,47 +393,75 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) + const toolAttachments: MessageV2.FilePart[] = ( + Array.isArray(value.output.attachments) ? value.output.attachments : [] + ).filter( + (attachment: unknown): attachment is MessageV2.FilePart => + isRecord(attachment) && + attachment.type === "file" && + typeof attachment.mime === "string" && + typeof attachment.url === "string", + ) + const normalized = yield* Effect.forEach(toolAttachments, (attachment) => + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), + ) + const omitted = normalized.filter(Exit.isFailure).length + const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) + const output = { + ...value.output, + output: + omitted === 0 + ? value.output.output + : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + attachments: attachments?.length ? attachments : undefined, + } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - structured: value.output.metadata, - content: [ - { - type: "text", - text: value.output.output, + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: output.metadata, + content: [ + { + type: "text", + text: output.output, + }, + ...(output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, }, - ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ - type: "file", - uri: item.url, - mime: item.mime, - name: item.filename, - })) ?? []), - ], - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - yield* completeToolCall(value.toolCallId, value.output) + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } + yield* completeToolCall(value.toolCallId, output) return } case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Failed.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - error: { - type: "unknown", - message: errorMessage(value.error), - }, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Failed.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* failToolCall(value.toolCallId, value.error) return } @@ -429,17 +473,19 @@ export const layer: Layer.Layer< if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Started.Sync, { - sessionID: ctx.sessionID, - agent: input.assistantMessage.agent, - model: { - id: Modelv2.ID.make(ctx.model.id), - providerID: Modelv2.ProviderID.make(ctx.model.providerID), - variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), - }, - snapshot: ctx.snapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } yield* session.updatePart({ id: PartID.ascending(), @@ -459,14 +505,16 @@ export const layer: Layer.Layer< }) if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Ended.Sync, { - sessionID: ctx.sessionID, - finish: value.finishReason, - cost: usage.cost, - tokens: usage.tokens, - snapshot: completedSnapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost @@ -514,10 +562,12 @@ export const layer: Layer.Layer< case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Started.Sync, { - sessionID: ctx.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.currentText = { id: PartID.ascending(), @@ -559,11 +609,13 @@ export const layer: Layer.Layer< )).text if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Ended.Sync, { - sessionID: ctx.sessionID, - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } { const end = Date.now() @@ -653,14 +705,16 @@ export const layer: Layer.Layer< } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Failed.Sync, { - sessionID: ctx.sessionID, - error: { - type: "unknown", - message: errorMessage(e), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: "unknown", + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { @@ -705,22 +759,28 @@ export const layer: Layer.Layer< parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Retried.Sync, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { - message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - return status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }) + const event = Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM + ? sync.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + : Effect.void + return event.pipe( + Effect.andThen( + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }), + ), + ) }, }), ), @@ -758,8 +818,10 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6b57d03026..312ca7964d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -53,7 +53,7 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" @@ -116,6 +116,7 @@ export const layer = Layer.effect( const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service + const sync = yield* SyncEvent.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() }) @@ -756,7 +757,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) const userMsg: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), sessionID: input.sessionID, @@ -807,12 +808,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } yield* sessions.updatePart(part) - EventV2.run(SessionEvent.Shell.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(started), - callID, - command: input.command, - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) + } return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -828,12 +831,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += "\n\n" + ["", "User aborted the command", ""].join("\n") } const completed = Date.now() - EventV2.run(SessionEvent.Shell.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(completed), - callID: part.callID, - output, - }) + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) + } if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) @@ -914,7 +919,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* Effect.failCause(exit.cause) }) - const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const current = Database.use((db) => + db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(), + ) + if (current?.model) { + return { + providerID: ProviderID.make(current.model.providerID), + modelID: ModelID.make(current.model.id), + ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), + } + } const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model return yield* provider.defaultModel() @@ -931,7 +946,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID)) + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same @@ -955,15 +977,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } - const current = Database.use((db) => - db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get(), - ) if (current?.agent !== info.agent) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { + yield* sync.run(SessionEvent.AgentSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), agent: info.agent, @@ -972,9 +987,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the if ( current?.model?.providerID !== info.model.providerID || current.model.id !== info.model.modelID || - current.model.variant !== info.model.variant + (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant ) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { + yield* sync.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), model: { @@ -1259,7 +1274,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( Effect.map((x) => x.flat().map(assign)), ) @@ -1272,9 +1287,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: input.messageID, variant: input.variant, }, - { message: info, parts }, + { message: info, parts: resolvedParts }, ) + const parts = resolvedParts + const parsed = MessageV2.Info.zod.safeParse(info) if (!parsed.success) { log.error("invalid user message before save", { @@ -1347,23 +1364,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Prompted.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - prompt: { - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - }, - }) - for (const text of nextPrompt.synthetic) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Synthetic.Sync, { + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Prompted.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - text, + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, }) } + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } + } return { info, parts } }, Effect.scoped) @@ -1698,7 +1719,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (cmdAgent?.model) return cmdAgent.model } if (input.model) return Provider.parseModel(input.model) - return yield* lastModel(input.sessionID) + return yield* currentModel(input.sessionID) }) yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) @@ -1731,7 +1752,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) - : yield* lastModel(input.sessionID) + : yield* currentModel(input.sessionID) : taskModel yield* plugin.trigger( @@ -1795,6 +1816,7 @@ export const defaultLayer = Layer.suspend(() => LLM.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, + SyncEvent.defaultLayer, ), ), ), diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index d0e6cd4cb7..caf8f9d783 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,34 +1,30 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: zod(s), })), ) export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe( +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: zod(s), })), ) export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe( +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: zod(s), })), ) diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e4e2e75b73..dde2e53d17 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( +export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0c97b9cdf7..ce58331ea3 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), @@ -20,6 +21,7 @@ export const GlobTool = Tool.define( Effect.gen(function* () { const rg = yield* Ripgrep.Service const fs = yield* AppFileSystem.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -39,11 +41,15 @@ export const GlobTool = Tool.define( let search = params.path ?? ins.directory search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) if (info?.type === "File") { throw new Error(`glob path must be a directory: ${search}`) } - yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), + kind: "directory", + }) const limit = 100 let truncated = false diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index fb3e70cad2..4e89198dff 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" const MAX_LINE_LENGTH = 2000 @@ -25,6 +26,7 @@ export const GrepTool = Tool.define( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -57,10 +59,12 @@ export const GrepTool = Tool.define( ? (params.path ?? ins.directory) : path.join(ins.directory, params.path ?? "."), ) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), kind: info?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 7ade166c5f..ad3c33e742 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { Reference } from "@/reference/reference" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -41,6 +42,7 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service + const reference = yield* Reference.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -162,6 +164,7 @@ export const ReadTool = Tool.define( if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } + yield* reference.ensure(filepath) const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( @@ -172,7 +175,7 @@ export const ReadTool = Tool.define( ) yield* assertExternalDirectoryEffect(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)), kind: stat?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 350c16664b..7b46a090e1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -50,6 +50,7 @@ import { Agent } from "../agent/agent" import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" +import { Reference } from "@/reference/reference" const log = Log.create({ service: "tool.registry" }) @@ -91,6 +92,7 @@ export const layer: Layer.Layer< | Session.Service | Provider.Service | Git.Service + | Reference.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -361,6 +363,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 969a3e66dd..2b5e41844e 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -1,11 +1,10 @@ -import path from "path" import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Flock } from "@opencode-ai/core/util/flock" import { Git } from "@/git" import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" -import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" +import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository" +import { RepositoryCache } from "@/reference/repository-cache" export const Parameters = Schema.Struct({ repository: Schema.String.annotate({ @@ -29,36 +28,6 @@ type Metadata = { branch?: string } -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false) return "refreshed" as const - if (input.refresh) return "refreshed" as const - return "cached" as const -} - -function resetTarget(input: { - requestedBranch?: string - remoteHead: { code: number; stdout: string } - branch: { code: number; stdout: string } -}) { - if (input.requestedBranch) return `origin/${input.requestedBranch}` - if (input.remoteHead.code === 0 && input.remoteHead.stdout) { - return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") - } - if (input.branch.code === 0 && input.branch.stdout) { - return `origin/${input.branch.stdout}` - } - return "HEAD" -} - -function validateBranch(branch: string) { - if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { - throw new Error( - "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", - ) - } -} - export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { @@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const reference = parseRepositoryReference(params.repository) - if (!reference) - throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") - if (params.branch) validateBranch(params.branch) + const reference = parseRemoteRepositoryReference(params.repository) + if (params.branch) validateRepositoryBranch(params.branch) const repository = reference.label const remote = reference.remote const localPath = repositoryCachePath(reference) - const cloneTarget = parseRepositoryReference(remote) ?? reference yield* ctx.ask({ permission: "repo_clone", @@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define Flock.acquire(`repo-clone:${localPath}`, { signal })), - () => - Effect.gen(function* () { - yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) - - const exists = yield* fs.existsSafe(localPath) - const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) - const origin = hasGitDir - ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) - : undefined - const originReference = - origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined - const reuse = - hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) - if (exists && !reuse) { - yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) - } - - const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined - const status = statusForRepository({ - reuse, - refresh: params.refresh, - branchMatches: params.branch ? currentBranch === params.branch : undefined, - }) - - if (status === "cloned") { - const clone = yield* git.run( - [ - "clone", - "--depth", - "100", - ...(params.branch ? ["--branch", params.branch] : []), - "--", - remote, - localPath, - ], - { cwd: path.dirname(localPath) }, - ) - if (clone.exitCode !== 0) { - throw new Error( - clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, - ) - } - } - - if (status === "refreshed") { - const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) - if (fetch.exitCode !== 0) { - throw new Error( - fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, - ) - } - - if (params.branch) { - const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], { - cwd: localPath, - }) - if (checkout.exitCode !== 0) { - throw new Error( - checkout.stderr.toString().trim() || - checkout.text().trim() || - `Failed to checkout ${params.branch}`, - ) - } - } - - const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) - const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) - const target = resetTarget({ - requestedBranch: params.branch, - remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, - branch: { code: branch.exitCode, stdout: branch.text().trim() }, - }) - - const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) - if (reset.exitCode !== 0) { - throw new Error( - reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, - ) - } - } - - const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) - const branch = yield* git.branch(localPath) - const headText = head.exitCode === 0 ? head.text().trim() : undefined - - return { - title: repository, - metadata: { - repository, - host: reference.host, - remote, - localPath, - status, - head: headText, - branch, - }, - output: [ - `Repository ready: ${repository}`, - `Status: ${status}`, - `Local path: ${localPath}`, - ...(branch ? [`Branch: ${branch}`] : []), - ...(headText ? [`HEAD: ${headText}`] : []), - ].join("\n"), - } - }), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + const result = yield* RepositoryCache.ensure( + { reference, refresh: params.refresh, branch: params.branch }, + { fs, git }, ) + return { + title: repository, + metadata: result, + output: [ + `Repository ready: ${repository}`, + `Status: ${result.status}`, + `Local path: ${localPath}`, + ...(result.branch ? [`Branch: ${result.branch}`] : []), + ...(result.head ? [`HEAD: ${result.head}`] : []), + ].join("\n"), + } }).pipe(Effect.orDie), } satisfies Tool.DefWithoutID }), diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index b6c263a4ce..a80d915153 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 2e78a94f41..890fb87a32 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -125,6 +125,21 @@ export function parseRepositoryReference(input: string) { } } +export function parseRemoteRepositoryReference(input: string) { + const reference = parseRepositoryReference(input) + if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + return reference +} + +export function validateRepositoryBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + export function parseGitHubRemote(input: string) { const cleaned = normalize(input) if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index 83ca437efe..14ee44dd52 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,7 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" import { withStatics } from "@opencode-ai/core/schema" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" export const ID = Schema.String.pipe( @@ -41,13 +40,4 @@ export function define( - def: Def, - data: SyncEvent.Event["data"], - options?: { publish?: boolean }, -) { - if (!Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) return - SyncEvent.run(def, data, options) -} - export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index b3da6009f6..3b0b61dcbc 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -12,6 +12,7 @@ import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" +import { SyncEvent } from "@/sync" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -113,6 +114,7 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const sync = yield* SyncEvent.Service const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -269,14 +271,14 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { + yield* sync.run(SessionEvent.AgentSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), agent: input.agent, }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { + yield* sync.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), model: input.model, @@ -311,6 +313,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(SyncEvent.defaultLayer)) export * as SessionV2 from "./session" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 69bbde01f1..52d4f709af 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -147,6 +147,10 @@ test("reference config creates scout-backed subagents", async () => { config: { reference: { effect: "github.com/effect/effect-smol", + effectDev: { + repository: "https://github.com/effect/effect-smol", + branch: "dev", + }, effectFull: { repository: "Effect-TS/effect", branch: "main", @@ -162,6 +166,7 @@ test("reference config creates scout-backed subagents", async () => { directory: tmp.path, fn: async () => { const effect = await load(tmp.path, (svc) => svc.get("effect")) + const effectDev = await load(tmp.path, (svc) => svc.get("effectDev")) const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) const local = await load(tmp.path, (svc) => svc.get("localdocs")) const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) @@ -169,13 +174,21 @@ test("reference config creates scout-backed subagents", async () => { expect(effect).toBeDefined() expect(effect?.mode).toBe("subagent") expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(evalPerm(effect, "repo_clone")).toBe("allow") + expect(effect?.prompt).toContain( + `Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`, + ) + expect(effect?.prompt).toContain("Do not call repo_clone") + expect(evalPerm(effect, "repo_clone")).toBe("deny") + + expect(effectDev).toBeDefined() + expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect") + expect(effectDev?.prompt).not.toContain("Cached directory:") expect(effectFull).toBeDefined() expect(effectFull?.mode).toBe("subagent") expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") expect(effectFull?.prompt).toContain("Branch/ref: main") - expect(evalPerm(effectFull, "repo_clone")).toBe("allow") + expect(evalPerm(effectFull, "repo_clone")).toBe("deny") expect(local).toBeDefined() expect(local?.mode).toBe("subagent") diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts index 34a16aedd6..a7b1643357 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -3,36 +3,27 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp describe("computePromptTraits", () => { test("normal mode without autocomplete only captures tab", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false }) expect(traits.capture).toEqual(["tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) test("normal mode with autocomplete captures navigation keys", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true }) expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) - test("shell mode does not suspend the textarea", () => { - // Suspending the textarea would gate every keybinding action - // (backspace, delete-word-backward, arrow movement, etc.) — see - // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is - // an active editing mode, so suspend must stay off. - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) - expect(traits.suspend).toBe(false) + test("shell mode does not write the keymap-owned suspend trait", () => { + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) + expect(traits.suspend).toBeUndefined() }) test("shell mode disables capture and labels the prompt", () => { - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) expect(traits.capture).toBeUndefined() expect(traits.status).toBe("SHELL") }) - - test("disabled suspends regardless of mode", () => { - expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - }) }) diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 279ed27d08..263f3a45f3 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -7,8 +7,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" function createTextPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "text" as const, text, } @@ -17,8 +17,8 @@ function createTextPart(text: string): MessageV2.Part { function createReasoningPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "reasoning" as const, text, time: { start: 0 }, @@ -29,8 +29,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn if (status === "completed") { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -46,8 +46,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn } return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -62,8 +62,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn function createStepStartPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-start" as const, } } @@ -71,8 +71,8 @@ function createStepStartPart(): MessageV2.Part { function createStepFinishPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-finish" as const, reason: "done", cost: 0, diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts new file mode 100644 index 0000000000..bf5c0b3948 --- /dev/null +++ b/packages/opencode/test/image/image.test.ts @@ -0,0 +1,82 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import { Image } from "@/image/image" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const tiny = testEffect( + Layer.mergeAll( + Image.layer.pipe( + Layer.provide( + TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), + ), + ), + ), +) + +function part(mime: string, data: string) { + return { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: SessionID.make("ses_test"), + type: "file" as const, + mime, + url: `data:${mime};base64,${data}`, + } +} + +describe("Image", () => { + it.effect("normalizes generated png and jpeg attachments", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage( + new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))), + 64, + 64, + ) + const image = yield* Image.Service + const results = yield* Effect.all([ + image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))), + image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))), + ]) + + source.free() + expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true]) + expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true) + }), + ) + + it.effect("accepts webp attachments that are already within limits", () => + Effect.gen(function* () { + const image = yield* Image.Service + const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + + expect(yield* image.normalize(input)).toEqual(input) + }), + ) + + tiny.effect("fails with a typed size error when no resized candidate fits", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1) + const image = yield* Image.Service + const exit = yield* image + .normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))) + .pipe(Effect.exit) + + source.free() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Image.SizeError) + if (error instanceof Image.SizeError) { + expect(error.width).toBe(1) + expect(error.height).toBe(1) + expect(error.max).toBe(1) + } + } + }), + ) +}) diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 2a1580579d..c476c108b4 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -22,8 +22,9 @@ function run(fn: (svc: Project.Interface) => Effect.Effect) { ) } -function uid() { - return SessionID.make(crypto.randomUUID()) +function legacySessionID() { + // Global-session migration covers persisted IDs from before prefixed session IDs. + return crypto.randomUUID() as SessionID } function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { @@ -73,7 +74,7 @@ describe("migrateFromGlobal", () => { expect(pre.id).toBe(ProjectID.global) // 2. Seed a session under "global" with matching directory - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 3. Make a commit so the project gets a real ID @@ -100,7 +101,7 @@ describe("migrateFromGlobal", () => { // 3. Seed a session under "global" with matching directory. // This simulates a session created before git init that wasn't // present when the real project row was first created. - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 4. Call fromDirectory again — project row already exists, @@ -121,7 +122,7 @@ describe("migrateFromGlobal", () => { // Legacy sessions may lack a directory value. // Without a matching origin directory, they should remain global. - const id = uid() + const id = legacySessionID() seed({ id, dir: "", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) @@ -139,7 +140,7 @@ describe("migrateFromGlobal", () => { ensureGlobal() // Seed a session under "global" but for a DIFFERENT directory - const id = uid() + const id = legacySessionID() seed({ id, dir: "/some/other/dir", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts new file mode 100644 index 0000000000..43427e4e66 --- /dev/null +++ b/packages/opencode/test/reference/reference.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Git } from "../../src/git" +import { Reference } from "../../src/reference/reference" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, Reference.defaultLayer), +) + +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.KILO_EXPERIMENTAL_SCOUT + Flag.KILO_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.KILO_EXPERIMENTAL_SCOUT = previous + }), + ) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) + +const waitForContent = ( + fs: AppFileSystem.Interface, + file: string, + content: string, + attempts = 50, +): Effect.Effect => + Effect.gen(function* () { + if ((yield* fs.readFileStringSafe(file)) === content) return + if (attempts <= 0) throw new Error(`timed out waiting for ${file}`) + yield* Effect.sleep("100 millis") + yield* waitForContent(fs, file, content, attempts - 1) + }) + +describe("reference", () => { + it.live("resolves local and git references", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const local = Reference.resolve({ + name: "docs", + reference: { path: "../docs" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + const repo = Reference.resolve({ + name: "effect", + reference: { repository: "Effect-TS/effect", branch: "main" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + + expect(local.kind).toBe("local") + if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs")) + expect(repo.kind).toBe("git") + if (repo.kind === "git") { + expect(repo.repository).toBe("Effect-TS/effect") + expect(repo.branch).toBe("main") + expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect")) + } + }), + ) + + it.live("marks same-cache references with different branches invalid", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const references = Reference.resolveAll({ + directory: root, + worktree: root, + references: { + main: { repository: "owner/repo", branch: "main" }, + dev: { repository: "github.com/owner/repo", branch: "dev" }, + alsoMain: { repository: "https://github.com/owner/repo", branch: "main" }, + }, + }) + + expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"]) + expect(references[1]?.kind).toBe("invalid") + if (references[1]?.kind === "invalid") { + expect(references[1].message).toContain("conflicts with @main") + expect(references[1].message).toContain("@dev requests dev") + } + }), + ) + + it.live("materializes configured git references during init", () => + experimentalScout( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) + + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", + }, + }, + }, + ), + ), + ) + + it.live("refreshes configured git references on new instance init", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 959c5fc3e8..014c38b261 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -17,14 +17,16 @@ import { ToolListQuery, } from "../../src/server/routes/instance/httpapi/groups/experimental" import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { ListQuery as SessionListQuery, MessagesQuery, SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" -import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" +import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" @@ -33,7 +35,14 @@ const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } -type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiSchema = { + readonly anyOf?: readonly OpenApiSchema[] + readonly enum?: readonly string[] + readonly maximum?: number + readonly minimum?: number + readonly pattern?: string + readonly type?: string +} type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } @@ -68,6 +77,28 @@ const numericSdkQueryParams = [ { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> +const booleanSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "roots" }, + { method: "get", path: ExperimentalPaths.session, name: "archived" }, + { method: "get", path: SessionPaths.list, name: "roots" }, + { method: "get", path: "/api/session", name: "roots" }, +] satisfies Array<{ method: Method; path: string; name: string }> + +const queryParamPatterns = [ + { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + +const pathParamPatterns = [ + { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" }, + { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" }, + { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" }, + { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" }, + { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" }, + { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" }, + { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" }, + { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + function app() { return Server.Default().app } @@ -98,6 +129,10 @@ function queryParameter(operation: OpenApiOperation | undefined, name: string) { return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) } +function pathParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name) +} + function assertAdvertisedQueryParamsAreRuntimeFields(input: { readonly method: Method readonly operation: OpenApiOperation | undefined @@ -148,7 +183,7 @@ describe("httpapi query schema drift", () => { ) it.effect( - "OpenAPI workspace query params are declared by runtime query schemas", + "OpenAPI query params are declared by runtime query schemas", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const route of openApiDriftRoutes) { @@ -161,7 +196,7 @@ describe("httpapi query schema drift", () => { ) it.effect( - "OpenAPI numeric query params preserve generated SDK call shapes", + "OpenAPI query and path schemas preserve compatibility metadata", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const expected of numericSdkQueryParams) { @@ -170,6 +205,24 @@ describe("httpapi query schema drift", () => { `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual(expected.schema) } + for (const expected of booleanSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(QueryBooleanOpenApi) + } + for (const expected of queryParamPatterns) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + for (const expected of pathParamPatterns) { + expect( + pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } }), ) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts new file mode 100644 index 0000000000..32165290a6 --- /dev/null +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import * as Database from "@/storage/db" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { MessageID, PartID } from "../../src/session/schema" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const withTmp = ( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) + +async function seedCorruptStepFinishPart(directory: string) { + return WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode — exact mirror of the + // corrupt row that broke the user's session in the OMO/Windows bug. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }) +} + +describe("schema-rejection wire shape", () => { + it.live( + "Payload schema rejection returns NamedError-shaped JSON, not empty", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(parsed.data.message).toEqual(expect.any(String)) + expect(parsed.data.message.length).toBeGreaterThan(0) + }), + ), + ) + + it.live( + "Query schema rejection returns NamedError-shaped JSON", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + // /find/file?limit=999999 violates the limit constraint check. + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) + }), + ), + ) + + it.live( + "rejected request body never echoes back unbounded — message is capped", + // Defense against DoS-amplification + secret-echo: Effect's Issue formatter + // dumps the rejected `actual` verbatim. A multi-MB invalid array would + // become a multi-MB 400 response and log line. Cap kicks in around 1KB. + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const huge = "X".repeat(50_000) + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. + expect(body.length).toBeLessThan(2 * 1024) + const parsed = JSON.parse(body) + expect(parsed.data.message).not.toContain(huge) + }), + ), + ) + + it.live( + "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) + // Field path in data.message — what made this PR worth shipping. + expect(parsed.data.message).toMatch(/output/) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 2da87ec868..64ff1d581f 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -287,7 +287,7 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - const response = await Server.Default().app.request("/session/nope") + const response = await Server.Default().app.request("/session/ses_nope") expect(response.status).toBe(404) }) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts index 31195dd021..fd259f9766 100644 --- a/packages/opencode/test/server/sdk-error-shape.test.ts +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -52,23 +52,33 @@ describe("v2 SDK error shape", () => { }) }) - test("400 with empty body throws a real Error naming the status", async () => { + test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => { + // Canary for the #26631 wire shape. Asserts the contract end-to-end: + // server emits {name:"BadRequest", data:{message, kind}}, SDK's + // wrapClientError extracts .data.message into Error.message. If either + // side regresses (#26457 reverted because both layers were missing), + // this test fails before users see (empty response body). await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) const sdk = client(tmp.path) let caught: unknown try { - // POST /sync/history with `aggregate: -1` triggers schema validation - // that returns an empty 400 body (verified via plan-mode probe). - await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true }) } catch (e) { caught = e } expect(caught).toBeInstanceOf(Error) const err = caught as Error - const cause = err.cause as { status?: number } - expect(err.message.length).toBeGreaterThan(0) + const cause = err.cause as { body?: any; status?: number } expect(cause.status).toBe(400) + expect(cause.body).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(typeof cause.body.data.message).toBe("string") + expect(cause.body.data.message.length).toBeGreaterThan(0) + // Whatever the server put in data.message must be what the user sees. + expect(err.message).toBe(cause.body.data.message) }) }) diff --git a/packages/opencode/test/server/sdk-v1-smoke.test.ts b/packages/opencode/test/server/sdk-v1-smoke.test.ts new file mode 100644 index 0000000000..5b91f3f2c4 --- /dev/null +++ b/packages/opencode/test/server/sdk-v1-smoke.test.ts @@ -0,0 +1,60 @@ +// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints +// against the current server. v1 generation has been frozen since #5216 +// (2025-12-07) so types may be stale, but runtime calls should still work +// for endpoints the v1 SDK was generated against. +import { afterEach, describe, expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk" +import { Server } from "../../src/server/server" +import { tmpdir, disposeAllInstances } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createKiloClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v1 SDK runtime smoke", () => { + test("session.list reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.list() + expect(result.error).toBeUndefined() + expect(Array.isArray(result.data)).toBe(true) + }) + + test("path.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.path.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("config.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.config.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("session 404: result-tuple path returns the error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.get({ path: { id: "ses_no_such" } as never }) + expect(result.error).toBeDefined() + // wire body for 404 is NamedError-shaped + expect(result.error).toMatchObject({ name: "NotFoundError" }) + }) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index cde9c1397f..8f987b4d10 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,20 +1,18 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" -import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import * as Stream from "effect/Stream" -import z from "zod" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -29,29 +27,10 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - messages(input: z.output) { - return run(SessionNs.Service.use((svc) => svc.messages(input))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, -} - const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ @@ -102,87 +81,109 @@ function createModel(opts: { const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) -async function user(sessionID: SessionID, text: string) { - const msg = await svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: ref, - time: { created: Date.now() }, +function createUserMessage(sessionID: SessionID, text: string) { + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg }) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg } -async function assistant(sessionID: SessionID, parentID: MessageID, root: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "build", - agent: "build", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - return msg +function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) { + return SessionNs.Service.use((ssn) => + ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + time: { created: Date.now() }, + finish: "end_turn", + }), + ) } -async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "compaction", - agent: "compaction", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - summary: true, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg +function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg + }), + ) } -async function lastCompactionPart(sessionID: SessionID) { - return (await svc.messages({ sessionID })) - .at(-2) - ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") +function createCompactionMarker(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: ref, + sessionID, + agent: "build", + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: false, + }) + }), + ) } function fake( @@ -216,33 +217,14 @@ function cfg(compaction?: Config.Info["compaction"]) { }) } -function runtime( - result: "continue" | "compact", - plugin = Plugin.defaultLayer, - provider = ProviderTest.fake(), - config = Config.defaultLayer, -) { - const bus = Bus.layer - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer, bus).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(layer(result)), - Layer.provide(Agent.defaultLayer), - Layer.provide(plugin), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - const deps = Layer.mergeAll( - ProviderTest.fake().layer, + wide().layer, layer("continue"), Agent.defaultLayer, Plugin.defaultLayer, Bus.layer, Config.defaultLayer, + SyncEvent.defaultLayer, ) const env = Layer.mergeAll( @@ -253,6 +235,58 @@ const env = Layer.mergeAll( const it = testEffect(env) +const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const itCompaction = testEffect(compactionEnv) + +type CompactionProcessOptions = { + result?: "continue" | "compact" + llm?: Layer.Layer + plugin?: Layer.Layer + provider?: ReturnType + config?: Layer.Layer +} + +function withCompaction(options?: CompactionProcessOptions) { + return Effect.provide(compactionProcessLayer(options)) +} + +function compactionProcessLayer(options?: CompactionProcessOptions) { + const bus = Bus.layer + const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const processor = options?.llm + ? SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(status), + ) + : layer(options?.result ?? "continue") + return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + Layer.provide(SessionNs.defaultLayer), + Layer.provide((options?.provider ?? wide()).layer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(options?.llm ?? LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(options?.plugin ?? Plugin.defaultLayer), + Layer.provide(status), + Layer.provide(bus), + Layer.provide(options?.config ?? Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + ) +} + +function createSummaryCompaction(sessionID: SessionID) { + return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) +} + +function readCompactionPart(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( + Effect.map((messages) => + messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), + ), + ) +} + function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -275,26 +309,6 @@ function llm() { } } -function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(layer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - function reply( text: string, capture?: (input: LLM.StreamInput) => void, @@ -350,23 +364,14 @@ function reply( } } -function wait(ms = 50) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function plugin(ready: ReturnType) { +function plugin(ready: Deferred.Deferred) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, _input: Input, output: Output) => { if (name !== "experimental.session.compacting") return Effect.succeed(output) - return Effect.sync(() => ready.resolve()).pipe(Effect.andThen(Effect.never), Effect.as(output)) + return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe( + Effect.andThen(Effect.never), + Effect.as(output), + ) }, list: () => Effect.succeed([]), init: () => Effect.void, @@ -801,319 +806,216 @@ describe("session.compaction.prune", () => { }) describe("session.compaction.process", () => { - test("throws when parent is not a user message", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const reply = await assistant(session.id, msg.id, tmp.path) - const rt = runtime("continue") - try { - const msgs = await svc.messages({ sessionID: session.id }) - await expect( - rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: reply.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ), - ).rejects.toThrow(`Compaction parent must be a user message: ${reply.id}`) - } finally { - await rt.dispose() - } - }, - }) - }) + it.instance( + "throws when parent is not a user message", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const reply = yield* createAssistantMessage(session.id, msg.id, test.directory) + const msgs = yield* ssn.messages({ sessionID: session.id }) - test("publishes compacted event on continue", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const done = defer() - let seen = false - const rt = runtime("continue", Plugin.defaultLayer, wide()) - let unsub: (() => void) | undefined - try { - unsub = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { - if (evt.properties.sessionID !== session.id) return - seen = true - done.resolve() - }), - ), - ) - - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - await Promise.race([ - done.promise, - wait(500).then(() => { - throw new Error("timed out waiting for compacted event") - }), - ]) - expect(result).toBe("continue") - expect(seen).toBe(true) - } finally { - unsub?.() - await rt.dispose() - } - }, - }) - }) - - test("marks summary message as errored on compact result", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("compact", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const summary = (await svc.messages({ sessionID: session.id })).find( - (msg) => msg.info.role === "assistant" && msg.info.summary, - ) - - expect(result).toBe("stop") - expect(summary?.info.role).toBe("assistant") - if (summary?.info.role === "assistant") { - expect(summary.info.finish).toBe("error") - expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") - } - } finally { - await rt.dispose() - } - }, - }) - }) - - test("adds synthetic continue prompt when auto is enabled", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) - - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) - - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts[0]).toMatchObject({ - type: "text", - synthetic: true, - metadata: { compaction_continue: true }, - }) - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("Continue if you have next steps") - } - } finally { - await rt.dispose() - } - }, - }) - }) - - test("persists tail_start_id for retained recent turns", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - const keep = await user(session.id, "second") - await user(session.id, "third") - await SessionCompaction.create({ + const exit = yield* Effect.exit( + SessionCompaction.use.process({ + parentID: reply.id, + messages: msgs, sessionID: session.id, - agent: "build", - model: ref, auto: false, - }) + }), + ) - const rt = runtime( - "continue", - Plugin.defaultLayer, - wide(), - cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }), - ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`) } - }, - }) - }) + } + }), + ) - test("shrinks retained tail to fit preserve token budget", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "x".repeat(2_000)) - const keep = await user(session.id, "tiny") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + it.instance( + "publishes compacted event on continue", + Effect.gen(function* () { + const bus = yield* Bus.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const done = yield* Deferred.make() + let seen = false + const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { + if (evt.properties.sessionID !== session.id) return + seen = true + Deferred.doneUnsafe(done, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) + expect(result).toBe("continue") + expect(seen).toBe(true) + }), + ) - test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "y".repeat(2_000)) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itCompaction.instance( + "marks summary message as errored on compact result", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("yyyy") - } finally { - await rt.dispose() - } - }, - }) - }) + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (msg) => msg.info.role === "assistant" && msg.info.summary, + ) - test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent image turn") - await svc.updatePart({ + expect(result).toBe("stop") + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role === "assistant") { + expect(summary.info.finish).toBe("error") + expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") + } + }).pipe(withCompaction({ result: "compact" })), + ) + + it.instance( + "adds synthetic continue prompt when auto is enabled", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) + + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts[0]).toMatchObject({ + type: "text", + synthetic: true, + metadata: { compaction_continue: true }, + }) + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("Continue if you have next steps") + } + }), + ) + + itCompaction.instance( + "persists tail_start_id for retained recent turns", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + const keep = yield* createUserMessage(session.id, "second") + yield* createUserMessage(session.id, "third") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), + ) + + itCompaction.instance( + "shrinks retained tail to fit preserve token budget", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "x".repeat(2_000)) + const keep = yield* createUserMessage(session.id, "tiny") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), + ) + + itCompaction.instance( + "falls back to full summary when even one recent turn exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "y".repeat(2_000)) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("yyyy") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) + }, + { git: true }, + ) + + itCompaction.instance( + "falls back to full summary when retained tail media exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent image turn") + yield* ssn.updatePart({ id: PartID.ascending(), messageID: recent.id, sessionID: session.id, @@ -1122,743 +1024,496 @@ describe("session.compaction.process", () => { filename: "big.png", url: `data:image/png;base64,${"a".repeat(4_000)}`, }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("recent image turn") - expect(captured).toContain("Attached image/png: big.png") - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("recent image turn") + expect(captured).toContain("Attached image/png: big.png") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }, + { git: true }, + ) - test("retains a split turn suffix when a later message fits the preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent turn") - const large = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + itCompaction.instance( + "retains a split turn suffix when a later message fits the preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent turn") + const large = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: large.id, sessionID: session.id, type: "text", text: "z".repeat(2_000), }) - const keep = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + const keep = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: keep.id, sessionID: session.id, type: "text", text: "keep tail", }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + expect(captured).toContain("zzzz") + expect(captured).not.toContain("keep tail") + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered[1]?.info.role).toBe("assistant") + expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }, + { git: true }, + ) + + itCompaction.instance( + "allows plugins to disable synthetic continue prompt", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) + + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("assistant") + expect( + all.some( + (msg) => + msg.info.role === "user" && + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), ), - ) + ), + ).toBe(false) + }).pipe(withCompaction({ plugin: autocontinue(false) })), + ) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + it.instance( + "replays the prior user turn on overflow when earlier context exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "root") + const replay = yield* createUserMessage(session.id, "image") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: replay.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "cat.png", + url: "https://example.com/cat.png", + }) + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) - expect(filtered[1]?.info.role).toBe("assistant") - expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) - expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - test("allows plugins to disable synthetic continue prompt", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", autocontinue(false), wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts.some((part) => part.type === "file")).toBe(false) + expect( + last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), + ).toBe(true) + }), + ) - expect(result).toBe("continue") - expect(last?.info.role).toBe("assistant") - expect( - all.some( - (msg) => - msg.info.role === "user" && - msg.parts.some( - (part) => - part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), - ), - ), - ).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) + it.instance( + "falls back to overflow guidance when no replayable turn exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "earlier") + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) - test("replays the prior user turn on overflow when earlier context exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "root") - const replay = await user(session.id, "image") - await svc.updatePart({ - id: PartID.ascending(), - messageID: replay.id, - sessionID: session.id, - type: "file", - mime: "image/png", - filename: "cat.png", - url: "https://example.com/cat.png", - }) - const msg = await user(session.id, "current") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - const last = (await svc.messages({ sessionID: session.id })).at(-1) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts.some((part) => part.type === "file")).toBe(false) - expect( - last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) - }) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") + } + }), + ) - test("falls back to overflow guidance when no replayable turn exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "earlier") - const msg = await user(session.id, "current") - - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) - - const last = (await svc.messages({ sessionID: session.id })).at(-1) - - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") - } - } finally { - await rt.dispose() - } - }, - }) - }) - - test("stops quickly when aborted during retry backoff", async () => { - const stub = llm() - const ready = defer() - stub.push( - Stream.fromAsyncIterable( - { - async *[Symbol.asyncIterator]() { - yield { type: "start" } as LLM.Event - throw new APICallError({ - message: "boom", - url: "https://example.com/v1/chat/completions", - requestBodyValues: {}, - statusCode: 503, - responseHeaders: { "retry-after-ms": "10000" }, - responseBody: '{"error":"boom"}', - isRetryable: true, - }) - }, - }, - (err) => err, - ), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = liveRuntime(stub.layer, wide()) - let off: (() => void) | undefined - let run: Promise<"continue" | "stop"> | undefined - try { - off = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== session.id) return - if (evt.properties.status.type !== "retry") return - ready.resolve() - }), - ), - ) - - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) - - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for retry status") - }), - ]) - - const start = Date.now() - abort.abort() - const result = await Promise.race([ - run.then((value) => ({ kind: "done" as const, value, ms: Date.now() - start })), - wait(250).then(() => ({ kind: "timeout" as const })), - ]) - - expect(result.kind).toBe("done") - if (result.kind === "done") { - expect(result.value).toBe("stop") - expect(result.ms).toBeLessThan(250) - } - } finally { - off?.() - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) - } - }, - }) - }) - - test("does not leave a summary assistant when aborted before processor setup", async () => { - const ready = defer() - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = runtime("continue", plugin(ready), wide()) - let run: Promise<"continue" | "stop"> | undefined - try { - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) - - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for compaction hook") - }), - ]) - - abort.abort() - expect(await run).toBe("stop") - - const all = await svc.messages({ sessionID: session.id }) - expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) - } finally { - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) - } - }, - }) - }) - - test("does not allow tool calls while generating the summary", async () => { - const stub = llm() - stub.push( - Stream.make( - { type: "start" } satisfies LLM.Event, - { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, - { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, - { - type: "finish-step", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - response: { id: "res", modelId: "test-model", timestamp: new Date() }, - providerMetadata: undefined, - usage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, + itCompaction.instance( + "stops quickly when aborted during retry backoff", + () => { + const stub = llm() + stub.push( + Stream.fromAsyncIterable( + { + async *[Symbol.asyncIterator]() { + yield { type: "start" } as LLM.Event + throw new APICallError({ + message: "boom", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: { "retry-after-ms": "10000" }, + responseBody: '{"error":"boom"}', + isRetryable: true, + }) }, }, - } satisfies LLM.Event, - { - type: "finish", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - totalUsage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - ), - ) + (err) => err, + ), + ) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const summary = (await svc.messages({ sessionID: session.id })).find( - (item) => item.info.role === "assistant" && item.info.summary, - ) - - expect(summary?.info.role).toBe("assistant") - expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) - - test("summarizes only the head while keeping recent tail out of summary input", async () => { - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await user(session.id, "and this one too") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const bus = yield* Bus.Service + const ready = yield* Deferred.make() + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { + if (evt.properties.sessionID !== session.id) return + if (evt.properties.status.type !== "retry") return + Deferred.doneUnsafe(ready, Effect.void) }) + yield* Effect.addFinalizer(() => Effect.sync(off)) - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") - } finally { - await rt.dispose() - } - }, - }) - }) - - test("anchors repeated compactions with the previous summary", async () => { - const stub = llm() - let captured = "" - stub.push(reply("summary one")) - stub.push( - reply("summary two", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - - const rt = liveRuntime(stub.layer, wide()) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - await user(session.id, "latest turn") - await SessionCompaction.create({ + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, sessionID: session.id, - agent: "build", - model: ref, auto: false, }) + .pipe(Effect.forkChild) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + const start = Date.now() + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) - expect(captured).toContain("") - expect(captured).toContain("summary one") - expect(captured.match(/summary one/g)?.length).toBe(1) - expect(captured).toContain("## Constraints & Preferences") - expect(captured).toContain("## Progress") - } finally { - await rt.dispose() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(Date.now() - start).toBeLessThan(250) } - }, - }) - }) + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) - test("keeps recent pre-compaction turns across repeated compactions", async () => { + itCompaction.instance( + "does not leave a summary assistant when aborted before processor setup", + () => + Effect.gen(function* () { + const ready = yield* Deferred.make() + return yield* Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + .pipe(Effect.forkChild) + + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) + const all = yield* ssn.messages({ sessionID: session.id }) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) + }).pipe(withCompaction({ plugin: plugin(ready) })) + }), + { git: true }, + ) + + itCompaction.instance( + "does not allow tool calls while generating the summary", + () => { + const stub = llm() + stub.push( + Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, + { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + ), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false }) + + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.summary, + ) + + expect(summary?.info.role).toBe("assistant") + expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + + itCompaction.instance( + "summarizes only the head while keeping recent tail out of summary input", + () => { + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createUserMessage(session.id, "and this one too") + yield* createCompactionMarker(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + + itCompaction.instance( + "anchors repeated compactions with the previous summary", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createCompactionMarker(session.id) + + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + yield* createUserMessage(session.id, "latest turn") + yield* createCompactionMarker(session.id) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + + itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const u1 = await user(session.id, "one") - const u2 = await user(session.id, "two") - const u3 = await user(session.id, "three") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const u1 = yield* createUserMessage(session.id, "one") + const u2 = yield* createUserMessage(session.id, "two") + const u3 = yield* createUserMessage(session.id, "three") + yield* createCompactionMarker(session.id) - const u4 = await user(session.id, "four") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const u4 = yield* createUserMessage(session.id, "four") + yield* createCompactionMarker(session.id) - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - const ids = filtered.map((msg) => msg.info.id) + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - expect(ids).not.toContain(u1.id) - expect(ids).not.toContain(u2.id) - expect(ids).toContain(u3.id) - expect(ids).toContain(u4.id) - expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) - expect( - filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const ids = filtered.map((msg) => msg.info.id) + + expect(ids).not.toContain(u1.id) + expect(ids).not.toContain(u2.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) + expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) + expect( + filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), + ).toBe(true) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) - test("ignores previous summaries when sizing the retained tail", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const keep = await user(session.id, "keep this turn") - const keepReply = await assistant(session.id, keep.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: keepReply.id, - sessionID: session.id, - type: "text", - text: "keep reply", - }) + itCompaction.instance( + "ignores previous summaries when sizing the retained tail", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const test = yield* TestInstance + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const keep = yield* createUserMessage(session.id, "keep this turn") + const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id - expect(firstCompaction).toBeTruthy() - await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) + yield* createCompactionMarker(session.id) + const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800)) - const recent = await user(session.id, "recent turn") - const recentReply = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: recentReply.id, - sessionID: session.id, - type: "text", - text: "recent reply", - }) + const recent = yield* createUserMessage(session.id, "recent turn") + const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createCompactionMarker(session.id) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), + ) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 3bb38c8786..5d40933954 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -61,7 +61,7 @@ const tmpWithFiles = (files: Record) => function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") - const messageID = MessageID.make("message-loaded-1") + const messageID = MessageID.make("msg_message-loaded-1") return [ { @@ -78,7 +78,7 @@ function loaded(filepath: string): MessageV2.WithParts[] { }, parts: [ { - id: PartID.make("part-loaded-1"), + id: PartID.make("prt_part-loaded-1"), messageID, sessionID, type: "tool", @@ -106,7 +106,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1")) expect(results).toEqual([]) }), ), @@ -122,7 +122,7 @@ describe("Instruction.resolve", () => { const results = yield* svc.resolve( [], path.join(dir, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), + MessageID.make("msg_message-test-2"), ) expect(results.length).toBe(1) expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) @@ -138,7 +138,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3")) expect(results).toEqual([]) }), ), @@ -149,7 +149,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + const id = MessageID.make("msg_message-claim-1") const first = yield* svc.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id) @@ -166,7 +166,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + const id = MessageID.make("msg_message-claim-2") const first = yield* svc.resolve([], filepath, id) yield* svc.clear(id) @@ -185,7 +185,7 @@ describe("Instruction.resolve", () => { const svc = yield* Instruction.Service const agents = path.join(dir, "subdir", "AGENTS.md") const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + const id = MessageID.make("msg_message-claim-3") const results = yield* svc.resolve(loaded(agents), filepath, id) expect(results).toEqual([]) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 7b96084832..2879d04812 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -354,7 +354,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-1"), + id: MessageID.make("msg_user-1"), sessionID, role: "user", time: { created: Date.now() }, @@ -438,7 +438,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-service-abort"), + id: MessageID.make("msg_user-service-abort"), sessionID, role: "user", time: { created: Date.now() }, @@ -529,7 +529,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-tools"), + id: MessageID.make("msg_user-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -644,7 +644,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-2"), + id: MessageID.make("msg_user-2"), sessionID, role: "user", time: { created: Date.now() }, @@ -759,7 +759,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-data-url"), + id: MessageID.make("msg_user-data-url"), sessionID, role: "user", time: { created: Date.now() }, @@ -880,7 +880,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-3"), + id: MessageID.make("msg_user-3"), sessionID, role: "user", time: { created: Date.now() }, @@ -995,7 +995,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-anthropic-tools"), + id: MessageID.make("msg_user-anthropic-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -1239,7 +1239,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-4"), + id: MessageID.make("msg_user-4"), sessionID, role: "user", time: { created: Date.now() }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 08629f5b1b..f742b7afc8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -102,9 +102,9 @@ function assistantInfo( function basePart(messageID: string, id: string) { return { - id: PartID.make(id), + id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`), sessionID, - messageID: MessageID.make(messageID), + messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`), } } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 226bab9864..56ff102430 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -6,6 +6,7 @@ import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "@/provider/provider" @@ -23,6 +24,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -165,10 +167,11 @@ const deps = Layer.mergeAll( LLM.defaultLayer, Provider.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, - SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3b0009d2b3..42c9a81cd2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -16,6 +16,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -45,9 +46,11 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -172,6 +175,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) @@ -180,6 +184,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), @@ -187,12 +192,17 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index e9628ce49f..67c438a386 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -15,20 +15,20 @@ import { WorkspaceID } from "../../src/control-plane/schema" // schema we assert: // 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input. // 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the -// same shape. -// 3. Clearly-invalid input is rejected by both paths. +// same shape for schemas that still expose Zod statics. +// 3. Clearly-invalid input is rejected by both paths where both exist. // // The point is to lock down the Schema <-> Zod bridge so a future edit to // any input schema can't silently drop or widen a field on one side. // Representative valid IDs — the branded schemas require the right prefix // (see src/id/id.ts). -const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") -const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") -const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") -const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") +const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") +const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") +const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") +const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") const projectID = ProjectID.zod.parse("proj-alpha") -const workspaceID = WorkspaceID.zod.parse("wrk-primary") +const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary") function decodeUnknown(schema: S) { const decode = Schema.decodeUnknownSync(schema as any) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 671f62145c..5c47df4c0d 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Question } from "../../src/question" +import { Image } from "../../src/image/image" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Todo } from "../../src/session/todo" @@ -56,6 +57,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -122,6 +125,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) @@ -130,6 +134,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), @@ -137,13 +142,18 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionSummary.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index fd24b557b3..3fc034e4e5 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -27,7 +27,7 @@ const runtime = ManagedRuntime.make( const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 23ae0e9090..a629ff07d1 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -17,7 +17,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 5914918178..0560ea0300 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -12,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 94f401afd8..45dc0b36a9 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -10,6 +10,7 @@ import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,12 +19,13 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 4b0da7c698..53f5d9a19c 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,12 +19,13 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 27623375c2..875af8e010 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 3f2cba8941..da215db770 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -10,7 +10,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-session"), - messageID: MessageID.make("test-message"), + messageID: MessageID.make("msg_test-message"), callID: "test-call", agent: "test-agent", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 969364bad9..c171401b1f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,6 +4,8 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -15,6 +17,7 @@ import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -24,7 +27,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), @@ -40,6 +43,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Reference.defaultLayer, Truncate.defaultLayer, ), ) @@ -81,6 +85,49 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.KILO_EXPERIMENTAL_SCOUT + Flag.KILO_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.KILO_EXPERIMENTAL_SCOUT = previous + }), + ) +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.KILO_REPO_CLONE_GITHUB_BASE_URL + }), + ) +const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(p, content) @@ -212,6 +259,46 @@ describe("tool.read external_directory permission", () => { expect(ext).toBeUndefined() }), ) + + it.live("does not ask for external_directory permission when reading configured references", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", + }, + }, + }) + + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") + + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), + ), + ) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index b3882a620b..8a3df421e2 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -25,6 +25,7 @@ import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" +import { Reference } from "@/reference/reference" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.KILO_EXPERIMENTAL_SCOUT @@ -42,6 +43,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts index 12f196b1c5..5e16e47735 100644 --- a/packages/opencode/test/tool/repo_clone.test.ts +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -19,7 +19,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "scout", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index b4214b7af4..556fa05d1f 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -18,7 +18,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "scout", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index b2e907020b..3554256bae 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -36,7 +36,7 @@ const initShell = initBash const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 745bac45f8..a059c244d4 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 6c7f6aba77..f3890c0161 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -13,7 +13,7 @@ const projectRoot = path.join(import.meta.dir, "../..") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make("message"), + messageID: MessageID.make("msg_message"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 8bba52a4b2..f6ac57a8ce 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-write-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 04d27ab212..f83fae1afc 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.3.22", + "version": "7.3.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 30bf4362ef..4bce46502c 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.3.22", + "version": "7.3.40", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index bf0381d5ce..c11468e559 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.3.22", + "version": "7.3.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 62e1b8fe8d..8fd2a02b92 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -752,11 +752,11 @@ export type Project = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type NotFoundError = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4779f7cebd..6b0f4c6f88 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1132,6 +1132,17 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + export type Config = { $schema?: string shell?: string @@ -1246,6 +1257,7 @@ export type Config = { tools?: { [key: string]: boolean } + attachment?: AttachmentConfig enterprise?: { url?: string } @@ -3284,11 +3296,11 @@ export type EventTuiToastShow1 = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type AuthRemoveData = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8e7d56a987..40b33147bf 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3398,7 +3398,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3459,7 +3459,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3550,7 +3550,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3614,7 +3614,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3747,7 +3747,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^que" }, "required": true }, @@ -3841,7 +3841,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^que" }, "required": true }, @@ -3963,7 +3963,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^per" }, "required": true }, @@ -4489,7 +4489,8 @@ "type": "object", "properties": { "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "title": { "type": "string" @@ -4517,7 +4518,8 @@ "$ref": "#/components/schemas/PermissionRuleset" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" } }, "additionalProperties": false @@ -4601,7 +4603,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4672,7 +4674,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4744,7 +4746,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4844,7 +4846,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4921,7 +4923,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4998,7 +5000,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5023,7 +5025,7 @@ "in": "query", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": false } @@ -5064,7 +5066,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5170,7 +5172,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5244,7 +5246,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -5324,7 +5327,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5333,7 +5336,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -5418,7 +5421,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5427,7 +5430,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -5501,7 +5504,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5553,7 +5556,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "additionalProperties": false @@ -5579,7 +5583,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5653,7 +5657,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5722,7 +5726,8 @@ "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["modelID", "providerID", "messageID"], @@ -5749,7 +5754,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5820,7 +5825,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5893,7 +5898,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5989,7 +5994,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6044,7 +6049,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -6124,7 +6130,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6198,7 +6204,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6221,7 +6228,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -6269,7 +6277,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6345,7 +6353,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6391,7 +6400,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6453,10 +6462,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["messageID"], @@ -6483,7 +6494,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6556,7 +6567,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6565,7 +6576,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^per" }, "required": true }, @@ -6657,7 +6668,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6666,7 +6677,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -6675,7 +6686,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^prt.*" + "pattern": "^prt" }, "required": true }, @@ -6746,7 +6757,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6755,7 +6766,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -6764,7 +6775,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^prt.*" + "pattern": "^prt" }, "required": true }, @@ -7016,7 +7027,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7046,7 +7058,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7284,7 +7297,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7356,7 +7369,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7402,7 +7415,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7448,7 +7461,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7504,7 +7517,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -8209,6 +8222,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -8491,7 +8505,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -8599,7 +8614,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -8635,7 +8651,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^wrk.*" + "pattern": "^wrk" }, "required": true }, @@ -8743,7 +8759,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -8751,7 +8768,8 @@ ] }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "copyChanges": { "type": "boolean" @@ -8781,7 +8799,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -9153,7 +9171,8 @@ "pattern": "^per" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "permission": { "type": "string" @@ -9177,7 +9196,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -9433,7 +9453,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -9450,7 +9471,8 @@ "pattern": "^que" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "questions": { "type": "array", @@ -9476,7 +9498,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -9496,7 +9519,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -9721,6 +9745,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -9805,7 +9830,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "title": { "type": "string" @@ -9880,10 +9906,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -9958,10 +9986,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -10008,7 +10038,8 @@ ] }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "modelID": { "type": "string" @@ -10111,13 +10142,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10158,13 +10192,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10203,13 +10240,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10374,13 +10414,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10553,13 +10596,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10585,13 +10631,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10608,13 +10657,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10669,13 +10721,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10692,13 +10747,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10721,13 +10779,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10762,13 +10823,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10800,13 +10864,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10819,7 +10886,8 @@ "type": "boolean" }, "tail_start_id": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["id", "sessionID", "messageID", "type", "auto"], @@ -10895,7 +10963,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -10904,7 +10973,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -10913,7 +10983,8 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", @@ -11001,10 +11072,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -11937,6 +12010,36 @@ "enum": ["auto", "stretch"], "description": "@deprecated Always uses stretch layout." }, + "ImageAttachmentConfig": { + "type": "object", + "properties": { + "auto_resize": { + "type": "boolean" + }, + "max_width": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_height": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_base64_bytes": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "AttachmentConfig": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/ImageAttachmentConfig" + } + }, + "additionalProperties": false + }, "Config": { "type": "object", "properties": { @@ -12267,6 +12370,9 @@ "type": "boolean" } }, + "attachment": { + "$ref": "#/components/schemas/AttachmentConfig" + }, "enterprise": { "type": "object", "properties": { @@ -12724,7 +12830,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -12733,7 +12840,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -12742,7 +12850,8 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", @@ -12830,10 +12939,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -13519,7 +13630,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13560,7 +13672,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13586,7 +13699,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13621,7 +13735,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13817,6 +13932,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -13831,7 +13947,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -13943,7 +14060,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -13981,10 +14099,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -14019,7 +14139,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -14061,13 +14182,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -14102,7 +14226,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -14140,7 +14265,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "type": "object", @@ -14148,7 +14274,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -14178,7 +14305,8 @@ "workspaceID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -14208,7 +14336,8 @@ "parentID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -14378,10 +14507,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -14434,7 +14565,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -14475,7 +14607,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -14516,7 +14649,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -14570,7 +14704,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -14611,7 +14746,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -14652,7 +14788,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14696,7 +14833,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14740,7 +14878,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -14800,7 +14939,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -14876,7 +15016,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -14917,7 +15058,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -14955,7 +15097,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -14996,7 +15139,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15037,7 +15181,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -15078,7 +15223,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -15122,7 +15268,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -15166,7 +15313,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15210,7 +15358,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15254,7 +15403,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15298,7 +15448,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15358,7 +15509,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15415,7 +15567,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15485,7 +15638,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15542,7 +15696,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { "type": "number" @@ -15586,7 +15741,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -15628,7 +15784,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15669,7 +15826,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15820,13 +15978,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "field": { "type": "string" @@ -15873,7 +16034,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -15905,7 +16067,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "diff": { "type": "array", @@ -15935,7 +16098,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "anyOf": [ @@ -16082,7 +16246,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "todos": { "type": "array", @@ -16112,7 +16277,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "status": { "$ref": "#/components/schemas/SessionStatus" @@ -16139,7 +16305,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -16163,7 +16330,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -16241,13 +16409,15 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "arguments": { "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["name", "sessionID", "arguments", "messageID"], @@ -16359,7 +16529,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -16486,7 +16657,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "exitCode": { "type": "integer", @@ -16514,7 +16686,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" } }, "required": ["id"], @@ -16538,7 +16711,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -16565,10 +16739,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -16592,7 +16768,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -16623,13 +16800,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -16653,7 +16833,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16680,7 +16861,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16707,7 +16889,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16737,7 +16920,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -16767,7 +16951,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -16861,7 +17046,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -16891,7 +17077,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -16921,7 +17108,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -16954,7 +17142,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -16987,7 +17176,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -17036,7 +17226,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -17115,7 +17306,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -17145,7 +17337,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -17172,7 +17365,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -17202,7 +17396,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17232,7 +17427,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17262,7 +17458,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17295,7 +17492,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17328,7 +17526,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17361,7 +17560,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17394,7 +17594,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17427,7 +17628,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17510,7 +17712,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17556,7 +17759,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17615,7 +17819,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17692,7 +17897,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { "type": "number" @@ -17725,7 +17931,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -17756,7 +17963,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17786,7 +17994,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17842,16 +18051,19 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "projectID": { "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "path": { "type": "string" @@ -18037,7 +18249,8 @@ "additionalProperties": false }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -18512,19 +18725,24 @@ }, "BadRequestError": { "type": "object", - "required": ["data", "errors", "success"], + "required": ["name", "data"], "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "name": { + "type": "string", + "enum": ["BadRequest"] }, - "success": { - "type": "boolean", - "enum": [false] + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["Params", "Headers", "Query", "Body", "Payload"] + } + } } } } diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 0a874b2191..0631e32fff 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.3.22", + "version": "7.3.40", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 43362e1a02..7d2e8c46f5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.3.22", + "version": "7.3.40", "type": "module", "license": "MIT", "exports": { diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch new file mode 100644 index 0000000000..2e43225562 --- /dev/null +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -0,0 +1,14 @@ +diff --git a/photon_rs.js b/photon_rs.js +index 8f4144d..b83e9a9 100644 +--- a/photon_rs.js ++++ b/photon_rs.js +@@ -4509,7 +4509,8 @@ module.exports.__wbindgen_init_externref_table = function() { + ; + }; + +-const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); ++// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset. ++const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); + const bytes = require('fs').readFileSync(path); + + const wasmModule = new WebAssembly.Module(bytes);