mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 11:46:17 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edaee143d9 | ||
|
|
4184149b90 | ||
|
|
00f063b381 | ||
|
|
ea010ab3a4 | ||
|
|
65c5c7e3f6 | ||
|
|
ee69a91f26 | ||
|
|
7456598cde | ||
|
|
c228fc4886 | ||
|
|
bb3f4cc3c7 | ||
|
|
8d9727be9f | ||
|
|
6401eeaea0 | ||
|
|
e7ecee5df2 | ||
|
+30 |
02f2725154 | ||
|
|
79c1544072 | ||
|
|
8cac010bac | ||
|
|
193f6be99c | ||
|
|
2a9f8e3a2c | ||
|
|
2c814120c7 | ||
|
|
18fccac6ff | ||
|
|
360e7b412d | ||
|
|
ad596fb42b | ||
|
|
74e92f73e0 |
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@opencode-ai/plugin": minor
|
||||
"@opencode-ai/sdk": minor
|
||||
"@opencode-ai/client": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
---
|
||||
|
||||
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: ideal-pseudocode
|
||||
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
|
||||
---
|
||||
|
||||
# Ideal Pseudocode
|
||||
|
||||
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
|
||||
|
||||
## Loop
|
||||
|
||||
One function per round. Never touch code before the user picks a direction.
|
||||
|
||||
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
|
||||
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
|
||||
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
|
||||
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
|
||||
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
|
||||
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
|
||||
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
|
||||
8. **Offer the next target** and stop.
|
||||
|
||||
## Style
|
||||
|
||||
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
|
||||
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
|
||||
- Prefer early return and `if` chains for control flow; matchers are for producing values.
|
||||
- Compare names against the question each caller is actually asking; rename toward the domain noun.
|
||||
|
||||
## Example round
|
||||
|
||||
Every round should read like this (abridged from a real one, on an Effect step-runner):
|
||||
|
||||
> `runStep` it is. The ideal pseudocode:
|
||||
>
|
||||
> ```ts
|
||||
> // One logical step: one settled model call, however many attempts it takes.
|
||||
> // An attempt can end without settling in two ways:
|
||||
> // transient provider failure -> retry the same call, same assistant message
|
||||
> // compaction rewrote history -> rebuild the request and call again
|
||||
> function runStep(session, promotable, step) {
|
||||
> while (true) {
|
||||
> const result = callModel(session, promotable, step)
|
||||
>
|
||||
> if (result.completed) return result
|
||||
>
|
||||
> if (result.retryable) {
|
||||
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
|
||||
> promotable = none // never re-promote on a second attempt
|
||||
> continue
|
||||
> }
|
||||
>
|
||||
> // compaction restarted the step: fresh request from rewritten history
|
||||
> promotable = none
|
||||
> step = result.step
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Comparing against the real thing, three deltas:
|
||||
>
|
||||
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
|
||||
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
|
||||
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
|
||||
>
|
||||
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
|
||||
>
|
||||
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
|
||||
@@ -61,6 +61,7 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -350,7 +351,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -362,7 +363,7 @@
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
@@ -470,7 +471,9 @@
|
||||
"packages/docs": {
|
||||
"name": "@opencode-ai/docs",
|
||||
"devDependencies": {
|
||||
"effect": "catalog:",
|
||||
"mint": "4.2.666",
|
||||
"prettier": "3.6.2",
|
||||
},
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
@@ -706,6 +709,7 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -1079,6 +1083,7 @@
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
@@ -1205,7 +1210,7 @@
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||
|
||||
@@ -1641,23 +1646,25 @@
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
|
||||
"@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
|
||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
|
||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
|
||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
|
||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
|
||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
@@ -6149,7 +6156,9 @@
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -6579,6 +6588,8 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||
@@ -6597,6 +6608,8 @@
|
||||
|
||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
|
||||
@@ -6925,6 +6938,8 @@
|
||||
|
||||
"aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -8045,6 +8060,8 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
|
||||
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
|
||||
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
|
||||
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
|
||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -155,12 +155,12 @@
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.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",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
|
||||
@@ -78,7 +78,10 @@ const streamText = LLM.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
|
||||
if (event.type === "finish")
|
||||
process.stdout.write(
|
||||
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
|
||||
)
|
||||
}),
|
||||
),
|
||||
Stream.runDrain,
|
||||
@@ -194,7 +197,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
event: Schema.String,
|
||||
initial: () => undefined,
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||
onHalt: () => [{ type: "finish", reason: "stop" }],
|
||||
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
||||
|
||||
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
||||
|
||||
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice" | "responseFormat">
|
||||
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
|
||||
|
||||
export class GenerateObjectResponse<T> {
|
||||
constructor(
|
||||
|
||||
@@ -75,6 +75,15 @@ const AnthropicThinkingBlock = Schema.Struct({
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
// Safety-filtered thinking arrives as an opaque encrypted `data` payload with
|
||||
// no visible text. It must round-trip verbatim so multi-turn thinking + tool
|
||||
// use conversations keep their reasoning continuity.
|
||||
const AnthropicRedactedThinkingBlock = Schema.Struct({
|
||||
type: Schema.tag("redacted_thinking"),
|
||||
data: Schema.String,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
const AnthropicToolUseBlock = Schema.Struct({
|
||||
type: Schema.tag("tool_use"),
|
||||
id: Schema.String,
|
||||
@@ -136,6 +145,7 @@ type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicTextBlock,
|
||||
AnthropicThinkingBlock,
|
||||
AnthropicRedactedThinkingBlock,
|
||||
AnthropicToolUseBlock,
|
||||
AnthropicServerToolUseBlock,
|
||||
AnthropicServerToolResultBlock,
|
||||
@@ -159,7 +169,7 @@ const AnthropicTool = Schema.Struct({
|
||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||
|
||||
const AnthropicToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
|
||||
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||
])
|
||||
|
||||
@@ -214,6 +224,9 @@ const AnthropicStreamBlock = Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
@@ -287,6 +300,12 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -297,7 +316,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
||||
auto: () => ({ type: "auto" as const }),
|
||||
none: () => undefined,
|
||||
none: () => ({ type: "none" as const }),
|
||||
required: () => ({ type: "any" as const }),
|
||||
tool: (name) => ({ type: "tool" as const, name }),
|
||||
})
|
||||
@@ -330,7 +349,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
const wireType = serverToolResultType(part.name)
|
||||
if (!wireType)
|
||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||
@@ -469,11 +491,16 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
content.push({
|
||||
type: "thinking",
|
||||
thinking: part.text,
|
||||
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
|
||||
})
|
||||
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
|
||||
// thinking; only signature-less parts carrying redactedData
|
||||
// round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
}
|
||||
content.push({ type: "thinking", thinking: part.text, signature })
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -542,7 +569,6 @@ const outputConfig = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||
@@ -551,7 +577,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// 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"
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
@@ -560,6 +586,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
)
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
const toolChoice =
|
||||
tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
@@ -596,7 +625,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
||||
if (reason === "max_tokens") return "length"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "refusal") return "content-filter"
|
||||
return "unknown"
|
||||
@@ -680,7 +709,9 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
||||
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 }),
|
||||
// The complete payload is irreducible provider replay state: subsequent
|
||||
// stateless requests must round-trip the typed result block verbatim.
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -740,6 +771,25 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
]
|
||||
}
|
||||
|
||||
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||
// payload as `redactedData` metadata (same model as Vercel's
|
||||
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||
if (block.type === "redacted_thinking" && block.data) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ redactedData: block.data }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block)
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
@@ -829,7 +879,10 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type ModelToolSchemaCompatibility,
|
||||
@@ -65,14 +66,15 @@ const BedrockToolResultBlock = Schema.Struct({
|
||||
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
|
||||
|
||||
const BedrockReasoningBlock = Schema.Struct({
|
||||
reasoningContent: Schema.Struct({
|
||||
reasoningText: Schema.optional(
|
||||
Schema.Struct({
|
||||
reasoningContent: Schema.Union([
|
||||
Schema.Struct({
|
||||
reasoningText: Schema.Struct({
|
||||
text: Schema.String,
|
||||
signature: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
Schema.Struct({ redactedContent: Schema.String }),
|
||||
]),
|
||||
})
|
||||
|
||||
const BedrockUserBlock = Schema.Union([
|
||||
@@ -180,6 +182,8 @@ const BedrockEvent = Schema.Struct({
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// Blob fields in Bedrock's JSON event stream are base64 strings.
|
||||
redactedContent: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -259,6 +263,13 @@ const reasoningSignature = (part: ReasoningPart) => {
|
||||
)
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string"
|
||||
? bedrock.redactedData
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
@@ -348,11 +359,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
content.push({
|
||||
reasoningContent: {
|
||||
reasoningText: { text: part.text, signature: reasoningSignature(part) },
|
||||
},
|
||||
})
|
||||
const signature = reasoningSignature(part)
|
||||
const redactedData = reasoningRedactedData(part)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ reasoningContent: { redactedContent: redactedData } })
|
||||
continue
|
||||
}
|
||||
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -392,8 +405,13 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// 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(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
|
||||
request.tools.length > 0
|
||||
? {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
}
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
@@ -430,9 +448,10 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string): FinishReason => {
|
||||
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
||||
if (reason === "max_tokens") return "length"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -461,7 +480,7 @@ interface ParserState {
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
|
||||
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -512,12 +531,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = reasoning.text
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
|
||||
: reasoning.redactedContent !== undefined
|
||||
? Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
bedrockMetadata({ redactedData: reasoning.redactedContent }),
|
||||
)
|
||||
: state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: reasoning.text
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
|
||||
: state.lifecycle,
|
||||
lifecycle,
|
||||
reasoningSignatures: reasoning.signature
|
||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
: state.reasoningSignatures,
|
||||
@@ -578,7 +605,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
},
|
||||
usage: state.pendingFinish?.usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
@@ -586,7 +619,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage)
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: {
|
||||
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
||||
usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
}
|
||||
|
||||
const exception = (
|
||||
@@ -619,8 +661,13 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason:
|
||||
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized:
|
||||
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
||||
? "tool-calls"
|
||||
: state.pendingFinish.reason.normalized,
|
||||
},
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
|
||||
@@ -313,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
const hasTools = request.tools.length > 0
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
@@ -329,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
contents: yield* lowerMessages(request),
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: toolsEnabled
|
||||
tools: hasTools
|
||||
? [
|
||||
{
|
||||
functionDeclarations: request.tools.map((tool) =>
|
||||
@@ -338,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
||||
toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
||||
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
|
||||
? generationConfig
|
||||
: undefined,
|
||||
@@ -382,10 +382,22 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
finishReason === "SAFETY" ||
|
||||
finishReason === "BLOCKLIST" ||
|
||||
finishReason === "PROHIBITED_CONTENT" ||
|
||||
finishReason === "SPII"
|
||||
finishReason === "SPII" ||
|
||||
finishReason === "MODEL_ARMOR" ||
|
||||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
|
||||
finishReason === "IMAGE_RECITATION" ||
|
||||
finishReason === "LANGUAGE"
|
||||
)
|
||||
return "content-filter"
|
||||
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
|
||||
if (
|
||||
finishReason === "MALFORMED_FUNCTION_CALL" ||
|
||||
finishReason === "UNEXPECTED_TOOL_CALL" ||
|
||||
finishReason === "NO_IMAGE" ||
|
||||
finishReason === "TOO_MANY_TOOL_CALLS" ||
|
||||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
|
||||
finishReason === "MALFORMED_RESPONSE"
|
||||
)
|
||||
return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -402,7 +414,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
reason: {
|
||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
raw: state.finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
@@ -164,11 +167,18 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const OpenAIChatEvent = Schema.Struct({
|
||||
choices: Schema.Array(OpenAIChatChoice),
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
})
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
@@ -184,7 +194,7 @@ export interface ParserState {
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: string
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
@@ -439,6 +449,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "length") return "length"
|
||||
if (reason === "content_filter") return "content-filter"
|
||||
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
||||
if (reason === "error") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -532,10 +543,22 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
|
||||
|
||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.error)
|
||||
return yield* new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices[0]
|
||||
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
|
||||
const choice = event.choices?.[0]
|
||||
const finishReason = choice?.finish_reason
|
||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
||||
: state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
@@ -627,7 +650,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const events: LLMEvent[] = []
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
const reason = state.finishReason
|
||||
? {
|
||||
...state.finishReason,
|
||||
normalized:
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: undefined
|
||||
const metadata = reasoningMetadata(
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
|
||||
@@ -979,7 +979,10 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, state.hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage),
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
||||
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
||||
|
||||
export interface State {
|
||||
readonly stepStarted: boolean
|
||||
@@ -81,7 +81,7 @@ export const finish = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
input: {
|
||||
readonly reason: FinishReason
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly usage?: Usage
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
|
||||
@@ -191,10 +191,16 @@ export const ToolError = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.ToolError" })
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
export const FinishReasonDetails = Schema.Struct({
|
||||
normalized: FinishReason,
|
||||
raw: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
||||
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
||||
|
||||
export const StepFinish = Schema.Struct({
|
||||
type: Schema.tag("step-finish"),
|
||||
index: Schema.Number,
|
||||
reason: FinishReason,
|
||||
reason: FinishReasonDetails,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
||||
@@ -202,7 +208,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const Finish = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
reason: FinishReason,
|
||||
reason: FinishReasonDetails,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
@@ -365,7 +371,7 @@ interface ResponseState {
|
||||
readonly events: ReadonlyArray<LLMEvent>
|
||||
readonly message: Message
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
||||
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
||||
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
||||
@@ -393,7 +399,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
finishReason: state.finishReason ?? "error",
|
||||
finishReason: state.finishReason ?? { normalized: "error" },
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -580,7 +586,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
||||
message: Message,
|
||||
events: Schema.Array(LLMEvent),
|
||||
usage: Schema.optional(Usage),
|
||||
finishReason: FinishReason,
|
||||
finishReason: FinishReasonDetails,
|
||||
}) {
|
||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||
get text() {
|
||||
|
||||
@@ -261,13 +261,6 @@ export namespace ToolChoice {
|
||||
}
|
||||
}
|
||||
|
||||
export const ResponseFormat = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text") }),
|
||||
Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
|
||||
Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
|
||||
|
||||
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
id: Schema.optional(Schema.String),
|
||||
model: ModelSchema,
|
||||
@@ -278,7 +271,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
generation: Schema.optional(GenerationOptions),
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
responseFormat: Schema.optional(ResponseFormat),
|
||||
cache: Schema.optional(CachePolicy),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
@@ -296,7 +288,6 @@ export namespace LLMRequest {
|
||||
generation: request.generation,
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
responseFormat: request.responseFormat,
|
||||
cache: request.cache,
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish"
|
||||
? { type: "finish", reason: event.reason }
|
||||
? { type: "finish", reason: { normalized: event.reason } }
|
||||
: { type: "text-delta", id: "text-0", text: event.text }
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
|
||||
@@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const assistantContent: ContentPart[] = []
|
||||
const toolCalls: ToolCallPart[] = []
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
|
||||
let usage: Usage | undefined
|
||||
let providerMetadata: ProviderMetadata | undefined
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("llm constructors", () => {
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
{ type: "finish", reason: { normalized: "stop" } },
|
||||
],
|
||||
}),
|
||||
).toBe("hi")
|
||||
|
||||
@@ -235,6 +235,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps tools and sends tool_choice none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
id: "req_tool_choice_none",
|
||||
model,
|
||||
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [
|
||||
Message.user("What is the weather?"),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
],
|
||||
toolChoice: "none",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look things up",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
])
|
||||
expect(prepared.body.tool_choice).toEqual({ type: "none" })
|
||||
}),
|
||||
)
|
||||
|
||||
// Regression: read tool results must stay structured so base64 media data is
|
||||
// not JSON-stringified into `tool_result.content`.
|
||||
it.effect("lowers media tool-result content as structured blocks", () =>
|
||||
@@ -381,6 +409,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
|
||||
{ type: "reasoning", text: "visible", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: "opaque_1" },
|
||||
{ type: "thinking", thinking: "visible", signature: "sig_1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -420,12 +476,149 @@ describe("Anthropic Messages route", () => {
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "redacted_thinking", data: "opaque_1" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-start")).toMatchObject({
|
||||
providerMetadata: { anthropic: { redactedData: "opaque_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
|
||||
{ type: "text", text: "Hello" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips streamed redacted thinking with tool use into a continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
// Anthropic types `redacted_thinking.data` as an opaque string. Its
|
||||
// contents are provider-owned and must be replayed without inspection.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "redacted_thinking", data: redactedData },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Say hello."),
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Say hello." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: redactedData },
|
||||
{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: "sunny",
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps context-window truncation to length", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "model_context_window_exceeded" },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves pause_turn while normalizing it to stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -475,10 +668,16 @@ describe("Anthropic Messages route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
@@ -636,10 +835,20 @@ describe("Anthropic Messages route", () => {
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
|
||||
// The complete payload rides in provider metadata as irreducible replay
|
||||
// state for later stateless requests.
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
blockType: "web_search_tool_result",
|
||||
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.text).toBe("Found it.")
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -154,6 +154,36 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.updateRequest(baseRequest, {
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
||||
},
|
||||
],
|
||||
toolChoice: ToolChoice.make({ type: "none" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig).toMatchObject({
|
||||
tools: [
|
||||
{
|
||||
toolSpec: {
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers assistant tool-call + tool-result message history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
@@ -260,7 +290,10 @@ describe("Bedrock Converse route", () => {
|
||||
// `metadata` (carries usage). We consolidate them into a single
|
||||
// terminal `finish` event with both.
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(finishes[0]).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
})
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
@@ -269,6 +302,23 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["model_context_window_exceeded", "length"],
|
||||
["malformed_model_output", "error"],
|
||||
["malformed_tool_use", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -332,7 +382,10 @@ describe("Bedrock Converse route", () => {
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -358,7 +411,7 @@ describe("Bedrock Converse route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -414,6 +467,72 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips streamed redacted reasoning with tool use into a continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
// Bedrock represents redactedContent blobs as base64 strings on its JSON
|
||||
// wire. The provider owns the payload and requires byte-exact replay.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(baseRequest, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
contentBlockIndex: 1,
|
||||
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||
},
|
||||
],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 1, delta: { toolUse: { input: '{"query":"weather"}' } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 1 }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Say hello."),
|
||||
response.message,
|
||||
Message.tool({ id: "tool_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Say hello." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ reasoningContent: { redactedContent: redactedData } },
|
||||
{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ toolResult: { toolUseId: "tool_1", content: [{ text: "sunny" }], status: "success" } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies throttlingException as a rate limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -233,11 +233,11 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits tools when tool choice is none", () =>
|
||||
it.effect("keeps tools and sends function calling mode NONE", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
id: "req_no_tools",
|
||||
id: "req_tool_choice_none",
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
@@ -245,8 +245,10 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
expect(prepared.body).toMatchObject({
|
||||
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||
tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
|
||||
toolConfig: { functionCallingConfig: { mode: "NONE" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -371,10 +373,16 @@ describe("Gemini route", () => {
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: "STOP" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: "STOP" },
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -527,10 +535,16 @@ describe("Gemini route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -569,7 +583,10 @@ describe("Gemini route", () => {
|
||||
},
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -589,9 +606,41 @@ describe("Gemini route", () => {
|
||||
)
|
||||
|
||||
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
|
||||
expect(length.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "length", raw: "MAX_TOKENS" },
|
||||
})
|
||||
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
|
||||
expect(filtered.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "content-filter", raw: "SAFETY" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["MODEL_ARMOR", "content-filter"],
|
||||
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
|
||||
["IMAGE_RECITATION", "content-filter"],
|
||||
["LANGUAGE", "content-filter"],
|
||||
["UNEXPECTED_TOOL_CALL", "error"],
|
||||
["NO_IMAGE", "error"],
|
||||
["IMAGE_OTHER", "unknown"],
|
||||
["TOO_MANY_TOOL_CALLS", "error"],
|
||||
["MISSING_THOUGHT_SIGNATURE", "error"],
|
||||
["MALFORMED_RESPONSE", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
|
||||
),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -569,10 +569,16 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -1037,8 +1043,14 @@ describe("OpenAI Chat route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
|
||||
{ type: "finish", reason: "tool-calls", usage: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "tool_calls" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -232,7 +232,10 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -856,13 +856,13 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: undefined },
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: undefined },
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
@@ -887,11 +887,18 @@ describe("OpenAI Responses route", () => {
|
||||
const length = yield* generate({ reason: "max_output_tokens" })
|
||||
const contentFilter = yield* generate({ reason: "content_filter" })
|
||||
const unknown = yield* generate({})
|
||||
const custom = yield* generate({ reason: "provider_limit" })
|
||||
|
||||
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
|
||||
"length",
|
||||
"content-filter",
|
||||
"unknown",
|
||||
expect([
|
||||
length.finishReason,
|
||||
contentFilter.finishReason,
|
||||
unknown.finishReason,
|
||||
custom.finishReason,
|
||||
]).toEqual([
|
||||
{ normalized: "length", raw: "max_output_tokens" },
|
||||
{ normalized: "content-filter", raw: "content_filter" },
|
||||
{ normalized: "unknown", raw: undefined },
|
||||
{ normalized: "unknown", raw: "provider_limit" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -946,8 +953,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
@@ -1038,8 +1045,8 @@ describe("OpenAI Responses route", () => {
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1422,10 +1429,16 @@ describe("OpenAI Responses route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
@@ -1465,7 +1478,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
@@ -1492,7 +1505,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
describe("OpenRouter", () => {
|
||||
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
|
||||
@@ -54,6 +56,42 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the upstream provider finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails on a mid-stream provider error", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
error: { code: 502, message: "Provider disconnected" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.message).toContain("Provider disconnected")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves manually supplied reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
|
||||
@@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({
|
||||
})
|
||||
|
||||
const expectCode = (response: LLMResponse) => {
|
||||
expect(response.finishReason).toBe("stop")
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.text.toUpperCase()).toContain(CODE)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("PDF recorded", () => {
|
||||
tools: { read_pdf: readPdfRuntime },
|
||||
}).pipe(Stream.runCollect),
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
|
||||
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
||||
|
||||
export const expectFinish = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
|
||||
reason: FinishReason,
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
|
||||
|
||||
export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
@@ -136,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const finishes = events.filter(LLMEvent.is.finish)
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]?.reason).toBe("stop")
|
||||
expect(finishes[0]?.reason.normalized).toBe("stop")
|
||||
|
||||
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
|
||||
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
|
||||
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
|
||||
|
||||
const toolCalls = events.filter(LLMEvent.is.toolCall)
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
@@ -503,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
|
||||
continue
|
||||
}
|
||||
if (event.type === "finish") {
|
||||
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
|
||||
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
|
||||
}
|
||||
}
|
||||
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
|
||||
|
||||
@@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
|
||||
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
|
||||
LLMEvent.textEnd({ id: "t1" }),
|
||||
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }),
|
||||
]
|
||||
const response = LLMResponse.fromEvents(events)
|
||||
|
||||
expect(response?.finishReason).toBe("stop")
|
||||
expect(response?.finishReason).toEqual({ normalized: "stop" })
|
||||
expect(response?.usage).toMatchObject({ outputTokens: 5 })
|
||||
expect(response?.events).toEqual(events)
|
||||
expect(response?.events.map((event) => event.type)).toEqual([
|
||||
@@ -62,18 +62,26 @@ describe("LLMResponse reducer", () => {
|
||||
|
||||
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
|
||||
const withFinishUsage = LLMResponse.fromEvents([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }),
|
||||
])
|
||||
const withoutFinishUsage = LLMResponse.fromEvents([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
|
||||
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
|
||||
})
|
||||
|
||||
test("preserves the raw finish reason", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
|
||||
])
|
||||
|
||||
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
|
||||
})
|
||||
|
||||
test("assembles tool-call content only after the completed tool call event", () => {
|
||||
const pending = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
@@ -88,7 +96,7 @@ describe("LLMResponse reducer", () => {
|
||||
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
|
||||
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([
|
||||
|
||||
@@ -48,8 +48,12 @@ describe("llm schema", () => {
|
||||
})
|
||||
|
||||
test("finish constructors accept usage input", () => {
|
||||
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
|
||||
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
|
||||
expect(
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
|
||||
).toBeInstanceOf(Usage)
|
||||
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
|
||||
Usage,
|
||||
)
|
||||
})
|
||||
|
||||
test("content part tagged union exposes guards", () => {
|
||||
|
||||
@@ -97,6 +97,7 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -114,6 +115,7 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
|
||||
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("passes through non-event fetches", async ({ page }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
@@ -53,8 +54,20 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: events,
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||
if (path === "/api/health" && config.protocol === "v2")
|
||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
@@ -83,10 +96,129 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (path === "/api/agent")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
||||
if (integration && route.request().method() === "GET")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
})
|
||||
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
||||
if (path === "/api/path")
|
||||
return json(route, {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
})
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
if (path === "/api/question/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||
})
|
||||
if (path === "/api/vcs")
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path === "/api/session") {
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => !directory || session.directory === directory)
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
!search ||
|
||||
String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||
return json(route, {
|
||||
data: data.map((session) => currentSession(session, config.directory)),
|
||||
cursor: { next },
|
||||
})
|
||||
}
|
||||
if (path === "/api/session/active") {
|
||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
||||
return json(route, {
|
||||
data: currentSession(session, config.directory),
|
||||
})
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
@@ -107,6 +239,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (currentMessagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: pageData.items.map(currentMessage).reverse(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
@@ -129,6 +279,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
function location(config: MockServerConfig) {
|
||||
return {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
}
|
||||
}
|
||||
|
||||
function currentPermission(value: unknown) {
|
||||
const permission = value as Record<string, unknown>
|
||||
if (permission.action) return permission
|
||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
||||
return {
|
||||
id: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
action: permission.permission,
|
||||
resources: permission.patterns ?? [],
|
||||
save: permission.always,
|
||||
metadata: permission.metadata,
|
||||
source:
|
||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
agent: session.agent ?? "build",
|
||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||
? { archived: session.time.archived }
|
||||
: {}),
|
||||
},
|
||||
title: session.title ?? session.id,
|
||||
location: {
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
},
|
||||
subpath: session.path,
|
||||
revert: session.revert,
|
||||
}
|
||||
}
|
||||
|
||||
function currentMessage(value: unknown) {
|
||||
const item = value as {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
||||
parts: Array<Record<string, unknown> & { type: string }>
|
||||
}
|
||||
if (item.info.role === "user") {
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "user",
|
||||
time: item.info.time,
|
||||
text: item.parts
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "assistant",
|
||||
time: item.info.time,
|
||||
agent: item.info.agent ?? "build",
|
||||
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
||||
cost: item.info.cost,
|
||||
tokens: item.info.tokens,
|
||||
error: item.info.error,
|
||||
content: item.parts.flatMap<unknown>((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
||||
if (part.type !== "tool") return []
|
||||
const state = part.state as Record<string, unknown>
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: part.id,
|
||||
name: part.tool,
|
||||
time: state.time ?? { created: item.info.time.created },
|
||||
state:
|
||||
state.status === "pending"
|
||||
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
||||
: state.status === "completed"
|
||||
? {
|
||||
status: "completed",
|
||||
input: state.input ?? {},
|
||||
structured: state.metadata ?? {},
|
||||
content: [{ type: "text", text: state.output ?? "" }],
|
||||
}
|
||||
: state.status === "error"
|
||||
? {
|
||||
status: "error",
|
||||
input: state.input ?? {},
|
||||
structured: state.metadata ?? {},
|
||||
content: [],
|
||||
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
||||
}
|
||||
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
||||
},
|
||||
]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
@@ -149,3 +408,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
function currentEvent(input: unknown) {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event"
|
||||
path: "/global/event" | "/event" | "/api/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
@@ -93,6 +93,21 @@ export async function installSseTransport<T>(
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const currentEvent = (input: unknown) => {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location:
|
||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
@@ -140,15 +155,13 @@ export async function installSseTransport<T>(
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
})
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(
|
||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||
)
|
||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
@@ -161,7 +174,10 @@ export async function installSseTransport<T>(
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
if (
|
||||
url.origin !== server ||
|
||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||
)
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
@@ -177,6 +193,10 @@ export async function installSseTransport<T>(
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
if (url.pathname === "/api/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves V2 events while adapting permission requests for existing consumers", () => {
|
||||
const current = {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
type: "permission.v2.asked",
|
||||
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
@@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => {
|
||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("merges adjacent current text deltas", () => {
|
||||
const current = (id: string, value: string) =>
|
||||
adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "hello ") },
|
||||
{ directory: "/repo", payload: current("evt_2", "world") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
export type ServerEvent = Event & { current?: OpenCodeEvent }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
if (event.type === "permission.v2.asked") {
|
||||
return {
|
||||
id: event.id,
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: event.data.id,
|
||||
sessionID: event.data.sessionID,
|
||||
permission: event.data.action,
|
||||
patterns: event.data.resources,
|
||||
always: event.data.save ?? [],
|
||||
metadata: event.data.metadata ?? {},
|
||||
tool:
|
||||
event.data.source?.type === "tool"
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
|
||||
: undefined,
|
||||
},
|
||||
current: event,
|
||||
} as ServerEvent
|
||||
}
|
||||
if (event.type === "permission.v2.replied")
|
||||
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.asked")
|
||||
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.replied")
|
||||
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.rejected")
|
||||
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.payload.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.payload.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
previous.directory === event.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
const data =
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent,
|
||||
}
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
output.push(event)
|
||||
return
|
||||
@@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
protocol: Promise<ServerProtocol>
|
||||
url: string
|
||||
client: ReturnType<typeof createSdkForServer>
|
||||
api: CompatibleApi
|
||||
currentApi: ServerApi
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
start: () => Promise<void> | undefined
|
||||
}
|
||||
createClient: (
|
||||
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
|
||||
) => ReturnType<typeof createSdkForServer>
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: server.http,
|
||||
})
|
||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
@@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
const resetHeartbeat = () => {
|
||||
lastEventAt = Date.now()
|
||||
if (heartbeat) clearTimeout(heartbeat)
|
||||
heartbeat = setTimeout(() => {
|
||||
attempt?.abort()
|
||||
}, HEARTBEAT_TIMEOUT_MS)
|
||||
}
|
||||
const clearHeartbeat = () => {
|
||||
if (!heartbeat) return
|
||||
clearTimeout(heartbeat)
|
||||
heartbeat = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
@@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (isStreamClosed(error, attempt?.signal)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
for await (const event of events) {
|
||||
streamErrorLogged = false
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
const legacy = "payload" in event
|
||||
if (legacy && event.payload.type === "sync") continue
|
||||
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
|
||||
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
@@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
@@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||
makeEventListener(document, "visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const legacy = (directory?: string) =>
|
||||
createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
directory,
|
||||
})
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
protocol,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
currentApi,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
@@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
}
|
||||
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
@@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
@@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
scope: serverSDK.scope,
|
||||
directory,
|
||||
client,
|
||||
api: createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "PATCH") {
|
||||
return Response.json({
|
||||
id: "ses_1",
|
||||
slug: "ses_1",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
|
||||
return new Response(undefined, { status: 204 })
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
||||
return Response.json({
|
||||
admittedSeq: 1,
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
data: { text: "hello" },
|
||||
delivery: "steer",
|
||||
})
|
||||
}
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const server = { url: "http://localhost:4096" }
|
||||
const api = createCompatibleApi({
|
||||
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
|
||||
current: createApiForServer({ server, fetch: fetcher }),
|
||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||
directory: "/repo",
|
||||
})
|
||||
return { api, requests }
|
||||
}
|
||||
|
||||
describe("createCompatibleApi", () => {
|
||||
test("routes V1 archive through the legacy session update", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
|
||||
|
||||
const url = new URL(requests[0]!.url)
|
||||
expect(url.pathname).toBe("/session/ses_1")
|
||||
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
||||
expect(requests[0]!.method).toBe("PATCH")
|
||||
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
|
||||
})
|
||||
|
||||
test("converts current prompts to the V1 prompt contract", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
})
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||
expect(await requests[0]!.json()).toMatchObject({
|
||||
messageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps V2 session actions on the current API", async () => {
|
||||
const { api, requests } = setup("v2")
|
||||
await api.session.archive({ sessionID: "ses_1" })
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
|
||||
expect(requests[0]!.method).toBe("POST")
|
||||
})
|
||||
|
||||
test("resolves protocol detection once across implementation methods", async () => {
|
||||
let detections = 0
|
||||
const resolved = Promise.resolve<"v1" | "v2">("v2")
|
||||
const protocol = new Proxy(resolved, {
|
||||
get(target, property) {
|
||||
if (property !== "then") return Reflect.get(target, property, target)
|
||||
detections++
|
||||
return target.then.bind(target)
|
||||
},
|
||||
})
|
||||
const { api } = setup(protocol)
|
||||
|
||||
await api.session.archive({ sessionID: "ses_1" })
|
||||
await api.session.list()
|
||||
|
||||
expect(detections).toBe(1)
|
||||
})
|
||||
|
||||
test("uses the global V1 session search endpoint", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,495 @@
|
||||
import type { ServerApi } from "./server"
|
||||
import type { ServerProtocol } from "./server-protocol"
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
Project,
|
||||
ProjectCurrent,
|
||||
SessionApi,
|
||||
SessionCommandInput,
|
||||
SessionCommandOutput,
|
||||
SessionCompactInput,
|
||||
SessionCompactOutput,
|
||||
SessionInfo,
|
||||
SessionPromptInput,
|
||||
SessionPromptOutput,
|
||||
SessionShellInput,
|
||||
SessionShellOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
type LegacyClient = OpencodeClient
|
||||
type LegacyFor = (directory?: string) => LegacyClient
|
||||
type CompatibleSessionApi = Omit<
|
||||
SessionApi,
|
||||
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
|
||||
> & {
|
||||
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
|
||||
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
|
||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||
}
|
||||
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
|
||||
type LegacyPrompt = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
type LegacyLocation = { directory?: string }
|
||||
type CompatibleInput = {
|
||||
protocol: Promise<ServerProtocol>
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function mime(uri: string) {
|
||||
const match = /^data:([^;,]+)/.exec(uri)
|
||||
return match?.[1] ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
function sessionInfo(session: Session): SessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID,
|
||||
agent: session.agent,
|
||||
model: session.model && {
|
||||
id: session.model.id,
|
||||
providerID: session.model.providerID,
|
||||
variant: session.model.variant,
|
||||
},
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: session.time,
|
||||
title: session.title,
|
||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||
subpath: session.path,
|
||||
revert: session.revert && {
|
||||
messageID: session.revert.messageID,
|
||||
partID: session.revert.partID,
|
||||
snapshot: session.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
||||
const v1 = createV1Api(input)
|
||||
return lazyApi(
|
||||
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
|
||||
input.current,
|
||||
)
|
||||
}
|
||||
|
||||
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
|
||||
const cache = new Map<PropertyKey, unknown>()
|
||||
return new Proxy(shape, {
|
||||
get(target, property, receiver) {
|
||||
const sample = Reflect.get(target, property, receiver)
|
||||
if (typeof sample === "function") {
|
||||
return (...args: unknown[]) =>
|
||||
implementation.then((value) => {
|
||||
const method = Reflect.get(value, property)
|
||||
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
|
||||
return Reflect.apply(method, value, args)
|
||||
})
|
||||
}
|
||||
if (sample === null || typeof sample !== "object") return sample
|
||||
if (cache.has(property)) return cache.get(property)
|
||||
const nested = lazyApi(
|
||||
implementation.then((value) => {
|
||||
const result = Reflect.get(value, property)
|
||||
if (result === null || typeof result !== "object") {
|
||||
throw new Error(`API namespace unavailable: ${String(property)}`)
|
||||
}
|
||||
return result
|
||||
}),
|
||||
sample,
|
||||
)
|
||||
cache.set(property, nested)
|
||||
return nested
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
|
||||
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
project: { id: "", directory: directory(value) ?? "" },
|
||||
},
|
||||
data,
|
||||
})
|
||||
|
||||
return {
|
||||
...input.current,
|
||||
session: {
|
||||
...input.current.session,
|
||||
async list(
|
||||
value?: Parameters<ServerApi["session"]["list"]>[0],
|
||||
options?: Parameters<ServerApi["session"]["list"]>[1],
|
||||
) {
|
||||
if (!value?.directory && value?.search !== undefined) {
|
||||
const result = await legacy().experimental.session.list(
|
||||
{
|
||||
roots: value.parentID === null ? true : undefined,
|
||||
search: value.search,
|
||||
limit: value.limit,
|
||||
},
|
||||
options,
|
||||
)
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
}
|
||||
const result = await legacy({ directory: value?.directory }).session.list({
|
||||
directory: value?.directory,
|
||||
roots: value?.parentID === null ? true : undefined,
|
||||
search: value?.search,
|
||||
limit: value?.limit,
|
||||
})
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
||||
const result = await legacy(value?.location ?? undefined).session.create({
|
||||
directory: directory(value?.location ?? undefined),
|
||||
})
|
||||
if (!result.data) throw new Error("Failed to create session")
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||
const result = await legacy().session.get(value)
|
||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async active() {
|
||||
const result = await legacy().session.status()
|
||||
return Object.fromEntries(
|
||||
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
},
|
||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||
},
|
||||
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.delete(value)
|
||||
},
|
||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||
const result = await legacy().session.fork(value)
|
||||
if (!result.data) throw new Error("Failed to fork session")
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||
await legacy().session.abort(value)
|
||||
},
|
||||
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
||||
await legacy().session.promptAsync({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
agent: value.agent,
|
||||
model: value.model,
|
||||
variant: value.variant,
|
||||
parts: [
|
||||
{ type: "text", text: value.text },
|
||||
...(value.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
mime: mime(file.uri),
|
||||
url: file.uri,
|
||||
filename: file.name,
|
||||
})),
|
||||
...(value.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
data: { text: value.text },
|
||||
delivery: value.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
async command(value: SessionCommandInput) {
|
||||
await legacy().session.command({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
command: value.command,
|
||||
arguments: value.arguments ?? "",
|
||||
agent: value.agent ?? undefined,
|
||||
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
|
||||
variant: value.model?.variant,
|
||||
parts: value.files?.map((file) => ({
|
||||
type: "file" as const,
|
||||
mime: mime(file.uri),
|
||||
url: file.uri,
|
||||
filename: file.name,
|
||||
})),
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
|
||||
delivery: value.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
async shell(value: SessionShellInput & LegacyPrompt) {
|
||||
await legacy().session.shell({
|
||||
sessionID: value.sessionID,
|
||||
command: value.command,
|
||||
agent: value.agent,
|
||||
model: value.model,
|
||||
})
|
||||
},
|
||||
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
|
||||
if (!value.model) throw new Error("A model is required to compact a V1 session")
|
||||
await legacy().session.summarize({
|
||||
sessionID: value.sessionID,
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "compaction",
|
||||
}
|
||||
},
|
||||
revert: {
|
||||
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
||||
await legacy().session.revert(value)
|
||||
return { messageID: value.messageID }
|
||||
},
|
||||
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
||||
await legacy().session.unrevert(value)
|
||||
},
|
||||
commit: input.current.session.revert.commit,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
},
|
||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||
const result = await legacy(value?.location).project.current()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
},
|
||||
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
const result = await legacy({ directory: project?.worktree }).project.update({
|
||||
...value,
|
||||
directory: project?.worktree,
|
||||
})
|
||||
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
||||
return result.data as Project
|
||||
},
|
||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||
const result = await legacy(value.location).worktree.list()
|
||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||
},
|
||||
},
|
||||
path: {
|
||||
...input.current.path,
|
||||
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).path.get()
|
||||
if (!result.data) throw new Error("Path unavailable")
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
vcs: {
|
||||
...input.current.vcs,
|
||||
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.get()
|
||||
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
||||
},
|
||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.status()
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
||||
const result = await legacy(value.location).vcs.diff({
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
})
|
||||
return located(
|
||||
(result.data ?? []).map((file) => ({
|
||||
file: file.file,
|
||||
patch: file.patch ?? "",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status ?? "modified",
|
||||
})),
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
file: {
|
||||
...input.current.file,
|
||||
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
|
||||
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
||||
const result = await legacy(value.location).find.files({
|
||||
query: value.query,
|
||||
type: value.type,
|
||||
limit: value.limit,
|
||||
})
|
||||
return located(
|
||||
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
...input.current.integration,
|
||||
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
|
||||
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
|
||||
(method, index) =>
|
||||
method.type === "api"
|
||||
? { type: "key" as const, label: method.label }
|
||||
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
|
||||
)
|
||||
return located(
|
||||
{
|
||||
id: value.integrationID,
|
||||
name: value.integrationID,
|
||||
methods,
|
||||
connections: [],
|
||||
},
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
connect: {
|
||||
...input.current.integration.connect,
|
||||
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
|
||||
await legacy(value.location).auth.set({
|
||||
providerID: value.integrationID,
|
||||
auth: { type: "api", key: value.key },
|
||||
})
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
...input.current.integration.oauth,
|
||||
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
|
||||
const method = Number(value.methodID)
|
||||
const result = await legacy(value.location).provider.oauth.authorize(
|
||||
{ providerID: value.integrationID, method, inputs: value.inputs },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
if (!result.data) throw new Error("Failed to start OAuth authorization")
|
||||
return located(
|
||||
{
|
||||
attemptID: `${value.integrationID}:${method}`,
|
||||
url: result.data.url,
|
||||
instructions: result.data.instructions,
|
||||
mode: result.data.method,
|
||||
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
|
||||
},
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
|
||||
const method = Number(value.attemptID.split(":").at(-1))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method, code: value.code },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
},
|
||||
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
|
||||
const method = Number(value.attemptID.split(":").at(-1))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return located(
|
||||
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
pty: {
|
||||
...input.current.pty,
|
||||
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
||||
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
||||
},
|
||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
|
||||
const result = await legacy(value?.location).pty.create({
|
||||
command: value?.command,
|
||||
args: value?.args ? [...value.args] : undefined,
|
||||
cwd: value?.cwd,
|
||||
title: value?.title,
|
||||
env: value?.env,
|
||||
})
|
||||
if (!result.data) throw new Error("Failed to create terminal")
|
||||
return located(result.data, value?.location)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
|
||||
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
|
||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
|
||||
const result = await legacy(value.location).pty.update({
|
||||
ptyID: value.ptyID,
|
||||
title: value.title,
|
||||
size: value.size,
|
||||
})
|
||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||
},
|
||||
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
||||
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
||||
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
...input.current.permission,
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
||||
await legacy().permission.respond({
|
||||
sessionID: value.sessionID,
|
||||
permissionID: value.requestID,
|
||||
response: value.reply,
|
||||
})
|
||||
},
|
||||
},
|
||||
question: {
|
||||
...input.current.question,
|
||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||
await legacy().question.reply({
|
||||
requestID: value.requestID,
|
||||
answers: value.answers.map((answer) => [...answer]),
|
||||
})
|
||||
},
|
||||
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
||||
await legacy().question.reject({ requestID: value.requestID })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
||||
|
||||
describe("checkServerHealth", () => {
|
||||
test("returns healthy response with version", async () => {
|
||||
const fetch = (async () =>
|
||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
let request: URL | undefined
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
})
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth(server, fetch)
|
||||
|
||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||
expect(request?.pathname).toBe("/api/health")
|
||||
})
|
||||
|
||||
test("falls back to the V1 health endpoint", async () => {
|
||||
const paths: string[] = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
paths.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
|
||||
return Response.json({ healthy: true, version: "1.18.4" })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||
})
|
||||
|
||||
test("falls back when the current health response is malformed", async () => {
|
||||
const paths: string[] = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
paths.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return Response.json({})
|
||||
return Response.json({ healthy: true, version: "1.18.4" })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||
})
|
||||
|
||||
test("allows slow servers thirty seconds by default", async () => {
|
||||
@@ -142,7 +172,7 @@ describe("checkServerHealth", () => {
|
||||
retryDelayMs: 1,
|
||||
})
|
||||
|
||||
expect(count).toBe(3)
|
||||
expect(count).toBe(6)
|
||||
expect(result).toEqual({ healthy: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createSdkForServer } from "./server"
|
||||
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
||||
import { ClientError, OpenCode } from "@opencode-ai/client"
|
||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
@@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
|
||||
|
||||
function retryable(error: unknown, signal?: AbortSignal) {
|
||||
if (signal?.aborted) return false
|
||||
if (error instanceof ClientError) return error.reason === "Transport"
|
||||
if (!(error instanceof Error)) return false
|
||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||
if (error instanceof TypeError) return true
|
||||
@@ -82,15 +84,31 @@ export async function checkServerHealth(
|
||||
.then(() => attempt(count + 1))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
const attempt = (count: number): Promise<ServerHealth> =>
|
||||
createSdkForServer({
|
||||
server,
|
||||
const attempt = async (count: number): Promise<ServerHealth> => {
|
||||
const current = await OpenCode.make({
|
||||
baseUrl: server.url,
|
||||
fetch,
|
||||
signal,
|
||||
headers: server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.health.get({ signal })
|
||||
.then((x) =>
|
||||
typeof x.healthy === "boolean"
|
||||
? { data: { healthy: x.healthy, version: x.version } }
|
||||
: { error: new Error("Invalid health response") },
|
||||
)
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current && current.data) return current.data
|
||||
if (signal?.aborted) return { healthy: false }
|
||||
|
||||
return createSdkForServer({ server, fetch, signal })
|
||||
.global.health()
|
||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch((error) => next(count, error))
|
||||
}
|
||||
return attempt(0).finally(() => timeout?.clear?.())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { detectServerProtocol } from "./server-protocol"
|
||||
|
||||
const server = { url: "http://localhost:4096" }
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
|
||||
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
|
||||
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
|
||||
|
||||
describe("detectServerProtocol", () => {
|
||||
test("prefers the legacy health endpoint when both API generations exist", async () => {
|
||||
const fetcher = mockFetch((input) => {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
|
||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
||||
})
|
||||
|
||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
||||
})
|
||||
|
||||
test("recognizes V2 health by its process identifier", async () => {
|
||||
const fetcher = mockFetch((input) => {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
||||
})
|
||||
|
||||
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
|
||||
})
|
||||
|
||||
test("recognizes the transitional V1 API health response", async () => {
|
||||
const fetcher = mockFetch((input) => {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
||||
return Promise.resolve(json({ healthy: true }))
|
||||
})
|
||||
|
||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { authTokenFromCredentials } from "./server"
|
||||
|
||||
export type ServerProtocol = "v1" | "v2"
|
||||
|
||||
function headers(server: ServerConnection.HttpBase) {
|
||||
if (!server.password) return
|
||||
return {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
|
||||
const response = await fetch(new URL(path, server.url), {
|
||||
headers: headers(server),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
|
||||
const value: unknown = await response.json()
|
||||
if (!value || typeof value !== "object") return
|
||||
return value
|
||||
}
|
||||
|
||||
export async function detectServerProtocol(
|
||||
server: ServerConnection.HttpBase,
|
||||
fetch: typeof globalThis.fetch,
|
||||
): Promise<ServerProtocol> {
|
||||
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
|
||||
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
|
||||
|
||||
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
|
||||
if (current && "pid" in current && typeof current.pid === "number") return "v2"
|
||||
if (current && "healthy" in current && current.healthy === true) return "v1"
|
||||
return "v2"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
@@ -39,3 +40,23 @@ export function createSdkForServer({
|
||||
baseUrl: server.url,
|
||||
})
|
||||
}
|
||||
|
||||
export function createApiForServer(input: {
|
||||
server: ServerConnection.HttpBase
|
||||
fetch?: typeof globalThis.fetch
|
||||
}): OpenCodeClient {
|
||||
return OpenCode.make({
|
||||
baseUrl: input.server.url,
|
||||
fetch: input.fetch,
|
||||
headers: input.server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({
|
||||
username: input.server.username,
|
||||
password: input.server.password,
|
||||
})}`,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export type ServerApi = OpenCodeClient
|
||||
|
||||
Binary file not shown.
@@ -28,7 +28,7 @@ export type TurnControl = {
|
||||
type ToolState = {
|
||||
readonly name: string
|
||||
input: ToolInput
|
||||
structured: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
content: ToolContent
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export type TurnStart =
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, structured: {}, content: [] }
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
|
||||
export async function streamTurn(input: {
|
||||
@@ -120,7 +120,7 @@ export async function streamTurn(input: {
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
|
||||
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
await update({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
@@ -151,15 +151,13 @@ export async function streamTurn(input: {
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(event.data.callID)
|
||||
if (!current) continue
|
||||
current.structured = event.data.structured
|
||||
current.content = event.data.content
|
||||
current.metadata = event.data.metadata
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
content: current.content,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
@@ -175,7 +173,7 @@ export async function streamTurn(input: {
|
||||
cwd: input.cwd,
|
||||
toolName: current.name,
|
||||
toolInput: current.input,
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata ?? {},
|
||||
}).catch(() => {})
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
@@ -183,9 +181,8 @@ export async function streamTurn(input: {
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
@@ -199,7 +196,7 @@ export async function streamTurn(input: {
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
structured: event.data.metadata ?? current.structured,
|
||||
metadata: event.data.metadata ?? current.metadata,
|
||||
content: event.data.content ?? current.content,
|
||||
error: event.data.error.message,
|
||||
cwd: input.cwd,
|
||||
@@ -342,9 +339,8 @@ async function replayMessage(
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
structured: part.state.structured,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
result: part.state.result,
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -358,7 +354,6 @@ async function replayMessage(
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.input },
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
@@ -373,7 +368,7 @@ async function replayMessage(
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
structured: part.state.structured,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
|
||||
@@ -58,11 +58,11 @@ export async function syncEditedFiles(input: {
|
||||
readonly cwd: string
|
||||
readonly toolName: string
|
||||
readonly toolInput: ToolInput
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly metadata: Readonly<Record<string, unknown>>
|
||||
}) {
|
||||
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
const files = Array.isArray(input.structured.files)
|
||||
? input.structured.files.flatMap((file): string[] => {
|
||||
const files = Array.isArray(input.metadata.files)
|
||||
? input.metadata.files.flatMap((file): string[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const path = Reflect.get(file, "file")
|
||||
return typeof path === "string" ? [path] : []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
import { readDisplayText } from "@opencode-ai/tui/mini/tool"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
@@ -100,11 +101,12 @@ export function completedToolUpdate(input: {
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly result?: unknown
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
|
||||
// Read's model content is a JSON page envelope; show the clean text instead.
|
||||
const firstText = input.content.find((part) => part.type === "text")
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||
const primary =
|
||||
read === undefined
|
||||
@@ -128,8 +130,7 @@ export function completedToolUpdate(input: {
|
||||
status: "completed",
|
||||
content: [...primary, ...diff, ...images],
|
||||
rawOutput: {
|
||||
structured: input.structured,
|
||||
...(input.result === undefined ? {} : { result: input.result }),
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -138,8 +139,8 @@ export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly content?: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
@@ -150,8 +151,11 @@ export function errorToolUpdate(input: {
|
||||
title: toolTitle(input.toolName, input.input, undefined),
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
||||
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
|
||||
rawOutput: { structured: input.structured, error: input.error },
|
||||
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
|
||||
rawOutput: {
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
error: input.error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,21 +168,6 @@ function toolContent(content: ToolContent): ToolCallContent[] {
|
||||
})
|
||||
}
|
||||
|
||||
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
|
||||
if (typeof structured.content === "string") {
|
||||
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
|
||||
}
|
||||
if (!Array.isArray(structured.entries)) return undefined
|
||||
return structured.entries
|
||||
.flatMap((entry): string[] => {
|
||||
if (typeof entry === "string") return [entry]
|
||||
if (!entry || typeof entry !== "object") return []
|
||||
const path = Reflect.get(entry, "path")
|
||||
return typeof path === "string" ? [path] : []
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
@@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||
import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||
import { UI } from "./ui"
|
||||
|
||||
type Model = {
|
||||
@@ -55,7 +55,7 @@ type ToolState = StartedPart & {
|
||||
raw?: string
|
||||
provider?: unknown
|
||||
providerState?: SessionMessageAssistantTool["providerState"]
|
||||
structured: Record<string, JsonValue>
|
||||
metadata: Record<string, JsonValue>
|
||||
content: LLMToolContent[]
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.name,
|
||||
input: {},
|
||||
structured: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
continue
|
||||
@@ -334,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
raw: current?.raw,
|
||||
provider: { executed: event.data.executed, state: event.data.state },
|
||||
providerState: event.data.state,
|
||||
structured: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
continue
|
||||
@@ -342,8 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (current) {
|
||||
current.structured = event.data.structured
|
||||
current.content = event.data.content
|
||||
current.metadata = event.data.metadata
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -360,9 +359,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
@@ -379,9 +377,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
output: toolOutputText(current.tool, event.data.content),
|
||||
title: current.tool,
|
||||
metadata: {
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
@@ -398,8 +395,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||
const current = tools.get(key) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const structured = event.data.metadata ?? current.structured
|
||||
const content = event.data.content ?? current.content
|
||||
const metadata = event.data.metadata ?? current.metadata
|
||||
const content = event.data.content ?? nonEmptyToolContent(current.content)
|
||||
const tool: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
@@ -410,10 +407,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
state: {
|
||||
status: "error",
|
||||
input: current.input,
|
||||
structured,
|
||||
metadata,
|
||||
content,
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
@@ -429,7 +425,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
input: current.input,
|
||||
error,
|
||||
metadata: {
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
@@ -441,15 +436,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
renderedTools.add(key)
|
||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||
if (!emit("tool_use", time, { part })) {
|
||||
if (toolOutputText(current.tool, content).trim())
|
||||
if (content && toolOutputText(current.tool, content).trim())
|
||||
await input.renderTool({
|
||||
...tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
structured,
|
||||
metadata,
|
||||
content,
|
||||
result: event.data.result,
|
||||
},
|
||||
})
|
||||
await input.renderToolError(tool)
|
||||
@@ -597,14 +591,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
input: item.state.input,
|
||||
output: toolOutputText(item.name, item.state.content),
|
||||
title: item.name,
|
||||
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||
metadata: { metadata: item.state.metadata, content: item.state.content },
|
||||
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||
}
|
||||
: {
|
||||
status: "error",
|
||||
input: item.state.input,
|
||||
error: item.state.error.message,
|
||||
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||
metadata: { metadata: item.state.metadata, content: item.state.content },
|
||||
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||
},
|
||||
}
|
||||
@@ -614,8 +608,16 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
await input.renderTool(item)
|
||||
continue
|
||||
}
|
||||
if (toolOutputText(item.name, item.state.content).trim()) {
|
||||
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
|
||||
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
|
||||
await input.renderTool({
|
||||
...item,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: item.state.input,
|
||||
metadata: item.state.metadata,
|
||||
content: item.state.content,
|
||||
},
|
||||
})
|
||||
}
|
||||
await input.renderToolError(item)
|
||||
UI.error(item.state.error.message)
|
||||
@@ -792,7 +794,7 @@ function fallbackTool(event: {
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: "tool",
|
||||
input: {},
|
||||
structured: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +218,7 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_ok",
|
||||
structured: { phase: 1 },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
@@ -227,9 +226,8 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_ok",
|
||||
structured: { exit: 0 },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
result: { code: 0 },
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
@@ -255,8 +253,7 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_fail",
|
||||
structured: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
@@ -313,12 +310,10 @@ describe("acp event behavior", () => {
|
||||
locations: [{ path: resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(updates[2]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "working" } }],
|
||||
})
|
||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
||||
expect(updates[3]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
@@ -327,7 +322,7 @@ describe("acp event behavior", () => {
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
],
|
||||
rawOutput: { structured: { bytes: 0 }, error: "not found" },
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
@@ -379,7 +374,7 @@ describe("acp event behavior", () => {
|
||||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||
],
|
||||
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[8]?.update).toMatchObject({
|
||||
toolCallId: "call_running",
|
||||
@@ -618,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
structured: { exit: 0 },
|
||||
metadata: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
result: { code: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -634,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -646,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
state: {
|
||||
status: "error",
|
||||
input: { filePath: "/workspace/missing.ts" },
|
||||
structured: { bytes: 0 },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
@@ -679,7 +672,7 @@ function replayToolMessage(id: string) {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
structured: { exit: 0 },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("acp permission behavior", () => {
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
toolInput: { filePath: "/workspace/file.ts" },
|
||||
structured: {},
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
expect(writes).toEqual([])
|
||||
@@ -193,7 +193,7 @@ describe("acp permission behavior", () => {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
callID: "call_edit",
|
||||
structured: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
executed: true,
|
||||
}),
|
||||
@@ -286,7 +286,7 @@ describe("acp permission behavior", () => {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
callID: "call_patch",
|
||||
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
executed: true,
|
||||
}),
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("acp tools", () => {
|
||||
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
|
||||
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
||||
],
|
||||
structured: {},
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
@@ -93,7 +93,7 @@ describe("acp tools", () => {
|
||||
content: "created",
|
||||
},
|
||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||
structured: {},
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
@@ -103,20 +103,22 @@ describe("acp tools", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("uses clean structured read content instead of model-facing formatting", () => {
|
||||
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-read",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file.ts" },
|
||||
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
|
||||
structured: {
|
||||
type: "text-page",
|
||||
content: "first\nsecond",
|
||||
mime: "text/plain",
|
||||
offset: 1,
|
||||
truncated: false,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
|
||||
|
||||
@@ -125,13 +127,17 @@ describe("acp tools", () => {
|
||||
toolCallId: "tool-list",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
content: [],
|
||||
structured: {
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
|
||||
})
|
||||
@@ -171,7 +177,7 @@ describe("acp tools", () => {
|
||||
newString: "after",
|
||||
},
|
||||
content: [{ type: "text", text: "Edit applied successfully." }],
|
||||
structured: { output: "Edit applied successfully." },
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
@@ -189,7 +195,7 @@ describe("acp tools", () => {
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
structured: { output: "Edit applied successfully." },
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -209,7 +215,7 @@ describe("acp tools", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("builds completed raw output with structured data and optional result", () => {
|
||||
test("builds completed raw output with optional metadata", () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: "file",
|
||||
@@ -225,12 +231,10 @@ describe("acp tools", () => {
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
result: "done",
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
}).rawOutput,
|
||||
).toEqual({
|
||||
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
result: "done",
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -239,9 +243,8 @@ describe("acp tools", () => {
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
structured: { output: "done" },
|
||||
}).rawOutput,
|
||||
).toEqual({ structured: { output: "done" } })
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test("extracts image attachments only from data URLs", () => {
|
||||
@@ -255,7 +258,7 @@ describe("acp tools", () => {
|
||||
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
|
||||
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
||||
],
|
||||
structured: {},
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
@@ -272,7 +275,7 @@ describe("acp tools", () => {
|
||||
toolName: "read",
|
||||
input: { filePath: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
structured: { path: "/tmp/a" },
|
||||
metadata: { path: "/tmp/a" },
|
||||
error: "failed",
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -286,7 +289,7 @@ describe("acp tools", () => {
|
||||
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||
{ type: "content", content: { type: "text", text: "failed" } },
|
||||
],
|
||||
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
|
||||
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,12 @@ describe("CLI frontend import boundaries", () => {
|
||||
|
||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
|
||||
expect(Object.keys(tool).sort()).toEqual([
|
||||
"nonEmptyToolContent",
|
||||
"readDisplayText",
|
||||
"toolInlineInfo",
|
||||
"toolOutputText",
|
||||
])
|
||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -134,15 +134,14 @@ function failedTool(inputID: string): V2Event[] {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
metadata: { checkpoint: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_failed_tool_terminal",
|
||||
created: 4,
|
||||
type: "session.tool.failed",
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
@@ -190,12 +189,12 @@ function successfulGrep(inputID: string): V2Event[] {
|
||||
id: "evt_grep_success",
|
||||
created: 3,
|
||||
type: "session.tool.success",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
callID: "call_grep",
|
||||
structured: { matches: 2 },
|
||||
metadata: { matches: 2 },
|
||||
content: [{ type: "text", text }],
|
||||
executed: false,
|
||||
},
|
||||
@@ -258,9 +257,7 @@ async function run(input: {
|
||||
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: input.messages?.(promptID) ?? [
|
||||
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
|
||||
],
|
||||
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
@@ -316,7 +313,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("runNonInteractivePrompt", () => {
|
||||
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
|
||||
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
|
||||
const output = await capture({ format: "json", turn: successfulGrep })
|
||||
const events = output.stdout
|
||||
.split("\n")
|
||||
@@ -332,13 +329,13 @@ describe("runNonInteractivePrompt", () => {
|
||||
status: "completed",
|
||||
output: expect.stringContaining("Found 2 matches"),
|
||||
metadata: {
|
||||
structured: { matches: 2 },
|
||||
metadata: { matches: 2 },
|
||||
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
|
||||
expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 })
|
||||
expect(events[0].part.state.metadata.result).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -534,7 +531,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "completed",
|
||||
structured: { checkpoint: 1 },
|
||||
metadata: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
},
|
||||
},
|
||||
@@ -544,7 +541,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "error",
|
||||
structured: { checkpoint: 1 },
|
||||
metadata: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
error: { message: "tool failed" },
|
||||
},
|
||||
@@ -574,7 +571,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
},
|
||||
})
|
||||
expect(events[0].part.state.output).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.structured).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.metadata).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.content).toBeUndefined()
|
||||
expect(output.stderr).toBe("")
|
||||
})
|
||||
|
||||
@@ -75,6 +75,10 @@ export const define = sdk.Plugin.define`
|
||||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
export const make = sdk.Tool.make`
|
||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
@@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError
|
||||
export const make = sdk.Tool.make
|
||||
export const validateName = sdk.Tool.validateName
|
||||
export const registrationEntries = sdk.Tool.registrationEntries
|
||||
export const withPermission = sdk.Tool.withPermission
|
||||
export const permission = sdk.Tool.permission
|
||||
export const definition = sdk.Tool.definition
|
||||
export const settle = sdk.Tool.settle`
|
||||
export const validateNamespace = sdk.Tool.validateNamespace
|
||||
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
|
||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||
import __cjs_mod__ from "node:module"
|
||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||
@@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url)
|
||||
const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
||||
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
|
||||
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
@@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({
|
||||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||
"opencode:plugin-v2-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
|
||||
@@ -104,6 +104,12 @@ export type SessionMessageProviderState = { [x: string]: JsonValue }
|
||||
|
||||
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
||||
|
||||
export type SessionMessageToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: JsonValue }
|
||||
metadata: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
|
||||
@@ -918,6 +924,15 @@ export type SessionToolInputDelta = {
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
|
||||
}
|
||||
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1809,28 +1824,19 @@ export type SessionPendingUserData1 = {
|
||||
metadata?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: JsonValue }
|
||||
structured: { [x: string]: JsonValue }
|
||||
content: Array<LLMToolContent>
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: JsonValue }
|
||||
content: Array<LLMToolContent>
|
||||
structured: { [x: string]: JsonValue }
|
||||
result?: JsonValue
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: JsonValue }
|
||||
content: Array<LLMToolContent>
|
||||
structured: { [x: string]: JsonValue }
|
||||
error: SessionStructuredError
|
||||
result?: JsonValue
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionToolSuccess = {
|
||||
@@ -1838,15 +1844,14 @@ export type SessionToolSuccess = {
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.success"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
content: Array<LLMToolContent>
|
||||
result?: any
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
@@ -1857,7 +1862,7 @@ export type SessionToolFailed = {
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
@@ -1865,28 +1870,12 @@ export type SessionToolFailed = {
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: any }
|
||||
result?: any
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
content: Array<LLMToolContent>
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
|
||||
@@ -33,7 +33,7 @@ const lookupOrder = Tool.make({
|
||||
description: "Look up an order by ID",
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
||||
run: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
@@ -55,10 +55,10 @@ const result = await Effect.runPromise(
|
||||
### `Tool.make`
|
||||
|
||||
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
|
||||
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
|
||||
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
|
||||
decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas
|
||||
only shape the model-visible signature. Without `output`, the signature uses `Promise<void>`.
|
||||
|
||||
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
|
||||
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
|
||||
|
||||
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
|
||||
`tools.issues.list(...)`. Other characters use bracket notation, such as
|
||||
|
||||
@@ -188,7 +188,7 @@ ultimate source of truth.
|
||||
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
|
||||
and a JavaScript `this` receiver remain outside the supported object/function model.
|
||||
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
||||
last definition supplied for a canonical path wins.
|
||||
last tool supplied for a canonical path wins.
|
||||
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
||||
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
|
||||
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
|
||||
|
||||
@@ -45,7 +45,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Provided>>(scope)
|
||||
const interpreter = new Interpreter<Services<Provided>>(
|
||||
tools.invoke,
|
||||
tools.execute,
|
||||
tools.search,
|
||||
tools.keys,
|
||||
promises,
|
||||
|
||||
@@ -271,7 +271,10 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" }
|
||||
|
||||
export class Interpreter<R> {
|
||||
private scopes: ScopeStack
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
private readonly executeTool: (
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
) => Effect.Effect<unknown, unknown, R>
|
||||
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
private readonly logs: Array<string>
|
||||
@@ -286,7 +289,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
constructor(
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
executeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
promises: PromiseRuntime<R>,
|
||||
@@ -294,7 +297,7 @@ export class Interpreter<R> {
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = new ScopeStack([globalScope])
|
||||
this.invokeTool = invokeTool
|
||||
this.executeTool = executeTool
|
||||
this.invokeSearch = invokeSearch
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
@@ -369,7 +372,7 @@ export class Interpreter<R> {
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
|
||||
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
@@ -2079,7 +2082,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
|
||||
const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
|
||||
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { make, type Definition } from "../tool.js"
|
||||
import { make, type Tool } from "../tool.js"
|
||||
import { invoke } from "./runtime.js"
|
||||
import {
|
||||
componentDefinitions,
|
||||
@@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(input.fields, requestDefinitions),
|
||||
output: output.value,
|
||||
run: (input) => invoke(plan, input),
|
||||
execute: (input) => invoke(plan, input),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -117,16 +117,16 @@ export const fromSpec = (options: Options): Result => {
|
||||
return { tools, skipped }
|
||||
}
|
||||
|
||||
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
|
||||
const setTool = (tools: Tools, path: ReadonlyArray<string>, tool: Tool<HttpClient.HttpClient>): void => {
|
||||
const [head, ...rest] = path
|
||||
if (head === undefined) return
|
||||
if (rest.length === 0) {
|
||||
tools[head] = definition
|
||||
tools[head] = tool
|
||||
return
|
||||
}
|
||||
const child = tools[head]
|
||||
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
||||
tools[head] = Object.create(null) as Tools
|
||||
}
|
||||
setTool(tools[head] as Tools, rest, definition)
|
||||
setTool(tools[head] as Tools, rest, tool)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import type { Definition, JsonSchema } from "../tool.js"
|
||||
import type { Tool, JsonSchema } from "../tool.js"
|
||||
|
||||
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
|
||||
export type Document = Record<string, unknown>
|
||||
@@ -58,7 +58,7 @@ export type Skipped = {
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
|
||||
export type Tools = { [name: string]: Tool<HttpClient.HttpClient> | Tools }
|
||||
|
||||
export type Result = {
|
||||
/** Namespaced tools; the host places them under a key in its `tools` object. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
inputTypeScript,
|
||||
outputTypeScript,
|
||||
} from "./tool-schema.js"
|
||||
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
|
||||
import { isTool, type Tool } from "./tool.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./values.js"
|
||||
|
||||
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
|
||||
export type Services<T> = ServicesOf<T, []>
|
||||
|
||||
@@ -28,7 +29,7 @@ type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] exten
|
||||
? never
|
||||
: T extends {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
}
|
||||
? R
|
||||
: T extends object
|
||||
@@ -118,8 +119,6 @@ export class ToolRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> => isToolDefinition<R>(value)
|
||||
|
||||
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
@@ -286,9 +285,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
|
||||
return value
|
||||
}
|
||||
|
||||
// Dots in tool names are namespace separators; the last definition for a canonical path wins.
|
||||
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
|
||||
type ToolNode<R> = {
|
||||
definition?: Definition<R>
|
||||
tool?: Tool<R>
|
||||
readonly children: Map<string, ToolNode<R>>
|
||||
}
|
||||
|
||||
@@ -303,7 +302,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
||||
current.children.set(segment, child)
|
||||
current = child
|
||||
}
|
||||
if (isDefinition(value)) current.definition = value
|
||||
if (isTool<R>(value)) current.tool = value
|
||||
else insert(current, value)
|
||||
}
|
||||
}
|
||||
@@ -314,26 +313,29 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
||||
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
|
||||
path.flatMap((segment) => segment.split("."))
|
||||
|
||||
const definitions = <R>(
|
||||
const flattenTools = <R>(
|
||||
node: ToolNode<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; definition: Definition<R> }> => [
|
||||
...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]),
|
||||
...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(),
|
||||
): Array<{ path: string; tool: Tool<R> }> => [
|
||||
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
|
||||
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
|
||||
]
|
||||
|
||||
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
|
||||
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
|
||||
path,
|
||||
description: definition.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
|
||||
description: tool.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
||||
})
|
||||
|
||||
const visibleDefinitions = <R>(tools: Tools<R>) =>
|
||||
definitions(toolTrie(tools)).map(({ path, definition }) => ({
|
||||
path,
|
||||
definition,
|
||||
description: describeDefinition(path, definition),
|
||||
}))
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visibleTools = <R>(tools: Tools<R>) =>
|
||||
flattenTools(toolTrie(tools))
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(({ path, tool }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
}))
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -361,12 +363,12 @@ const termForms = (term: string): Array<string> => {
|
||||
return forms
|
||||
}
|
||||
|
||||
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
|
||||
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
_tag: "CodeModeTool",
|
||||
description: "Search available tools",
|
||||
input: SearchInput,
|
||||
output: SearchOutput,
|
||||
run: (input) =>
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
const request = input as typeof SearchInput.Type
|
||||
const query = request.query ?? ""
|
||||
@@ -405,7 +407,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
|
||||
right.score - left.score || compareText(left.entry.description.path, right.entry.description.path),
|
||||
)
|
||||
.map(({ entry }) => entry)
|
||||
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
|
||||
@@ -422,8 +424,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
})
|
||||
|
||||
const searchSignature = (() => {
|
||||
const definition = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||
const tool = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
||||
})()
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
@@ -432,13 +434,13 @@ const catalogLine = (tool: ToolDescription) => {
|
||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||
}
|
||||
|
||||
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
searchText: [
|
||||
path,
|
||||
definition.description,
|
||||
...inputProperties(definition).flatMap(({ name, description: property }) =>
|
||||
tool.description,
|
||||
...inputProperties(tool).flatMap(({ name, description: property }) =>
|
||||
property === undefined ? [name] : [name, property],
|
||||
),
|
||||
]
|
||||
@@ -447,14 +449,14 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
})
|
||||
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
|
||||
|
||||
// Budget signatures round-robin so every namespace remains visible.
|
||||
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||
}
|
||||
const visible = visibleDefinitions(tools)
|
||||
const visible = visibleTools(tools)
|
||||
const described = visible.map(({ description }) => description)
|
||||
|
||||
const namespaces = new Map<string, Array<ToolDescription>>()
|
||||
@@ -464,14 +466,14 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
||||
group.push(tool)
|
||||
namespaces.set(namespace, group)
|
||||
}
|
||||
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||
const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right))
|
||||
|
||||
const selections = ordered.map(([namespace, group]) => ({
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
queue: [...group].sort(
|
||||
(left, right) =>
|
||||
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path),
|
||||
),
|
||||
}))
|
||||
let used = 0
|
||||
@@ -589,7 +591,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
||||
return {
|
||||
catalog: described,
|
||||
instructions: lines.join("\n"),
|
||||
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
|
||||
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,7 +607,7 @@ const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Reado
|
||||
return Array.from(node.children.keys())
|
||||
}
|
||||
|
||||
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<R> => {
|
||||
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> => {
|
||||
const segments = canonicalSegments(path)
|
||||
const node = lookup(root, segments)
|
||||
if (node === undefined) {
|
||||
@@ -613,16 +615,16 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<
|
||||
"Use search({ query }) to find available described tools.",
|
||||
])
|
||||
}
|
||||
if (node.definition === undefined) {
|
||||
if (node.tool === undefined) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
|
||||
}
|
||||
return node.definition
|
||||
return node.tool
|
||||
}
|
||||
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly execute: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
@@ -676,7 +678,7 @@ export const make = <R>(
|
||||
return calls.length - 1
|
||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||
|
||||
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
|
||||
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
@@ -688,7 +690,7 @@ export const make = <R>(
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||
@@ -705,18 +707,18 @@ export const make = <R>(
|
||||
keys: (path) => namespaceKeys(root, path),
|
||||
search: (args) =>
|
||||
Effect.suspend(() =>
|
||||
invokeDefinition(
|
||||
executeTool(
|
||||
"search",
|
||||
searchTool,
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
||||
),
|
||||
),
|
||||
invoke: (path, args) =>
|
||||
execute: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
const name = canonicalSegments(path).join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
||||
const tool = resolve(root, path)
|
||||
return yield* invokeDefinition(name, tool, externalArgs)
|
||||
return yield* executeTool(name, tool, externalArgs)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { JsonPointer, Schema } from "effect"
|
||||
import type { Definition, JsonSchema, SchemaType } from "./tool.js"
|
||||
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
|
||||
|
||||
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
||||
|
||||
@@ -192,16 +192,16 @@ export type InputProperty = {
|
||||
readonly required: boolean
|
||||
}
|
||||
|
||||
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(definition.input)
|
||||
? (Schema.toJsonSchemaDocument(definition.input) as {
|
||||
const document = isEffectSchema(tool.input)
|
||||
? (Schema.toJsonSchemaDocument(tool.input) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
: {
|
||||
schema: definition.input,
|
||||
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
|
||||
schema: tool.input,
|
||||
definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) },
|
||||
}
|
||||
const definitions = document.definitions ?? {}
|
||||
let schema = document.schema
|
||||
@@ -223,22 +223,22 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
|
||||
}
|
||||
}
|
||||
|
||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
isEffectSchema(definition.input)
|
||||
? toTypeScript(definition.input, false, pretty)
|
||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
|
||||
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
definition.output === undefined
|
||||
? "unknown"
|
||||
: isEffectSchema(definition.output)
|
||||
? toTypeScript(definition.output, true, pretty)
|
||||
: jsonSchemaToTypeScript(definition.output, pretty)
|
||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
tool.output === undefined
|
||||
? "void"
|
||||
: isEffectSchema(tool.output)
|
||||
? toTypeScript(tool.output, true, pretty)
|
||||
: jsonSchemaToTypeScript(tool.output, pretty)
|
||||
|
||||
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
||||
export const decodeInput = <R>(tool: Tool<R>, value: unknown): unknown =>
|
||||
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value
|
||||
|
||||
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
definition.output !== undefined && isEffectSchema(definition.output)
|
||||
? Schema.decodeUnknownSync(definition.output)(value)
|
||||
: value
|
||||
export const decodeOutput = <R>(tool: Tool<R>, value: unknown): unknown =>
|
||||
tool.output === undefined
|
||||
? undefined
|
||||
: isEffectSchema(tool.output)
|
||||
? Schema.decodeUnknownSync(tool.output)(value)
|
||||
: value
|
||||
|
||||
@@ -29,29 +29,29 @@ export type JsonSchema = {
|
||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
|
||||
export type Definition<R = never> = {
|
||||
/** Executable tool exposed through CodeMode's `tools` object. */
|
||||
export type Tool<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
readonly input: SchemaType
|
||||
readonly output: SchemaType | undefined
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||
}
|
||||
|
||||
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||
|
||||
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||
type ResultType<S> = S extends undefined ? void : S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||
|
||||
/** Options for defining one CodeMode tool. */
|
||||
/** Options for declaring one CodeMode tool. */
|
||||
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
|
||||
readonly description: string
|
||||
readonly input: I
|
||||
readonly output?: O
|
||||
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
||||
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
||||
}
|
||||
|
||||
// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition.
|
||||
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
|
||||
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
|
||||
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"_tag" in value &&
|
||||
@@ -59,18 +59,18 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
|
||||
value._tag === "CodeModeTool"
|
||||
|
||||
/**
|
||||
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
|
||||
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
|
||||
*
|
||||
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
|
||||
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
|
||||
* Without `output`, results are exposed as `void`. Hosts remain responsible for authorization
|
||||
* and durable side effects.
|
||||
*/
|
||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||
options: Options<I, O, R>,
|
||||
): Definition<R> => ({
|
||||
): Tool<R> => ({
|
||||
_tag: "CodeModeTool",
|
||||
description: options.description,
|
||||
input: options.input,
|
||||
output: options.output,
|
||||
run: (input) => options.run(input as InputType<I>),
|
||||
execute: (input) => options.execute(input as InputType<I>),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Definition } from "./tool.js"
|
||||
import type { Tool } from "./tool.js"
|
||||
|
||||
export type Tools<R = never> = {
|
||||
readonly [name: string]: Definition<R> | Tools<R>
|
||||
readonly [name: string]: Tool<R> | Tools<R>
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const echo = Tool.make({
|
||||
description: "Echo the input",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: (input: { id: number }) => Effect.succeed(input.id),
|
||||
execute: (input: { id: number }) => Effect.succeed(input.id),
|
||||
})
|
||||
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
|
||||
const toolError = async (code: string) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
|
||||
const run = (tool: Tool.Definition<never>) =>
|
||||
const run = (tool: Tool.Tool<never>) =>
|
||||
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
||||
|
||||
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
|
||||
@@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Fail safely",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.fail(toolError("Authorized request was refused")),
|
||||
execute: () => Effect.fail(toolError("Authorized request was refused")),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Fail safely",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
||||
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Fail internally",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => failure,
|
||||
execute: () => failure,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Return invalid output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ safe: Schema.String }),
|
||||
run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
|
||||
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Return hostile output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Unknown,
|
||||
run: () =>
|
||||
execute: () =>
|
||||
Effect.succeed(
|
||||
new Proxy(
|
||||
{},
|
||||
@@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Refuse",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.fail(toolError("Refused")),
|
||||
execute: () => Effect.fail(toolError("Refused")),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Interrupt",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.interrupt,
|
||||
execute: () => Effect.interrupt,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: ({ query }) => Effect.succeed(query),
|
||||
execute: ({ query }) => Effect.succeed(query),
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
@@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
@@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
properties: { id: { type: "string" }, count: { type: "number" } },
|
||||
required: ["id"],
|
||||
},
|
||||
run: (input) =>
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return { echoed: input }
|
||||
@@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => {
|
||||
{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<unknown>",
|
||||
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<void>",
|
||||
},
|
||||
])
|
||||
|
||||
// JSON Schema is render-only: mistyped input passes through unvalidated.
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } })
|
||||
if (result.ok) expect(result.value).toBeNull()
|
||||
expect(observed).toStrictEqual([{ id: 42 }])
|
||||
})
|
||||
|
||||
@@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
const call = Tool.make({
|
||||
description: "Observe raw input",
|
||||
input: { type: "object" },
|
||||
run: (input) =>
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return "ok"
|
||||
@@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
const find = Tool.make({
|
||||
description: "Find things",
|
||||
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
|
||||
run: (input) =>
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return "ok"
|
||||
@@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
run: () => Effect.succeed({ login: "kit", id: 7 }),
|
||||
execute: () => Effect.succeed({ login: "kit", id: 7 }),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||
|
||||
@@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => {
|
||||
if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
|
||||
})
|
||||
|
||||
test("Effect Schema output without an input transform still renders unknown when omitted", async () => {
|
||||
test("Effect Schema output without an input transform renders void when omitted", async () => {
|
||||
const ping = Tool.make({
|
||||
description: "Ping",
|
||||
input: Schema.Struct({ host: Schema.String }),
|
||||
run: () => Effect.succeed("pong"),
|
||||
execute: () => Effect.succeed("pong"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { net: { ping } } })
|
||||
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<unknown>")
|
||||
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.value).toBe("pong")
|
||||
if (result.ok) expect(result.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -554,7 +554,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Look up an order by ID",
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
||||
run: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||
})
|
||||
const tools = { orders: { lookup } }
|
||||
const source = `return await tools.orders.lookup({ id: "order_42" })`
|
||||
@@ -577,7 +577,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "echo",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Number,
|
||||
run: () => Effect.succeed(1),
|
||||
execute: () => Effect.succeed(1),
|
||||
})
|
||||
const effect = CodeMode.execute({
|
||||
tools: { host: { echo } },
|
||||
@@ -629,12 +629,47 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("renders equivalent catalogs identically regardless of tool insertion order", () => {
|
||||
const alpha = Tool.make({
|
||||
description: "Alpha tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Void,
|
||||
execute: () => Effect.void,
|
||||
})
|
||||
const zeta = Tool.make({
|
||||
description: "Zeta tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Void,
|
||||
execute: () => Effect.void,
|
||||
})
|
||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.instructions()).toBe(second.instructions())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
|
||||
for (const catalogBudget of [0, 10, 20, 40]) {
|
||||
expect(
|
||||
CodeMode.make({
|
||||
tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } },
|
||||
discovery: { catalogBudget },
|
||||
}).instructions(),
|
||||
).toBe(
|
||||
CodeMode.make({
|
||||
tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } },
|
||||
discovery: { catalogBudget },
|
||||
}).instructions(),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
const resolveLibrary = Tool.make({
|
||||
description: "Resolve a library ID",
|
||||
input: Schema.Struct({ libraryName: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
|
||||
execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
@@ -760,18 +795,18 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions).not.toContain("search(")
|
||||
})
|
||||
|
||||
test("uses one ranked search returning complete definitions for large catalogs", async () => {
|
||||
test("uses one ranked search returning complete tools for large catalogs", async () => {
|
||||
const upload = Tool.make({
|
||||
description: "Upload one readable local file to the current Discord thread",
|
||||
input: Schema.Struct({ path: Schema.String }),
|
||||
output: Schema.Struct({ sent: Schema.Boolean }),
|
||||
run: () => Effect.succeed({ sent: true }),
|
||||
execute: () => Effect.succeed({ sent: true }),
|
||||
})
|
||||
const generate = Tool.make({
|
||||
description: "Generate an image and upload it to the current Discord thread",
|
||||
input: Schema.Struct({ prompt: Schema.String }),
|
||||
output: Schema.Struct({ sent: Schema.Boolean }),
|
||||
run: () => Effect.succeed({ sent: true }),
|
||||
execute: () => Effect.succeed({ sent: true }),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
@@ -865,7 +900,7 @@ describe("CodeMode public contract", () => {
|
||||
description: `Numbered tool ${index}`,
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -911,7 +946,7 @@ describe("CodeMode public contract", () => {
|
||||
description,
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -954,13 +989,13 @@ describe("CodeMode public contract", () => {
|
||||
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
|
||||
required: ["attachment"],
|
||||
},
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const other = Tool.make({
|
||||
description: "Rename the workspace",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
|
||||
|
||||
@@ -990,7 +1025,7 @@ describe("CodeMode public contract", () => {
|
||||
description,
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -1029,7 +1064,7 @@ describe("CodeMode public contract", () => {
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
// Deliberately declared out of alphabetical order.
|
||||
const runtime = CodeMode.make({
|
||||
@@ -1071,7 +1106,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Cheap",
|
||||
input: Schema.Struct({ q: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const expensive = Tool.make({
|
||||
description:
|
||||
@@ -1081,7 +1116,7 @@ describe("CodeMode public contract", () => {
|
||||
anotherEvenLongerParameterName: Schema.Number,
|
||||
}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
|
||||
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
|
||||
@@ -1112,7 +1147,7 @@ describe("CodeMode public contract", () => {
|
||||
},
|
||||
required: ["id"],
|
||||
} as const,
|
||||
run: () => Effect.succeed("ok"),
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { records: { lookup: documented } },
|
||||
@@ -1130,7 +1165,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Double a number",
|
||||
input: Schema.Struct({ value: Schema.NumberFromString }),
|
||||
output: Schema.NumberFromString,
|
||||
run: ({ value }) =>
|
||||
execute: ({ value }) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(value)
|
||||
return String(value * 2)
|
||||
@@ -1226,7 +1261,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Count invocations",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Number,
|
||||
run: () => Effect.succeed(1),
|
||||
execute: () => Effect.succeed(1),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
|
||||
@@ -13,7 +13,7 @@ const echo = (description: string) =>
|
||||
description,
|
||||
input: Schema.Struct({ value: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: ({ value }) => Effect.succeed(value),
|
||||
execute: ({ value }) => Effect.succeed(value),
|
||||
})
|
||||
|
||||
const tools = {
|
||||
|
||||
@@ -143,12 +143,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
const remove = toolAt(api.tools, "users.remove")
|
||||
|
||||
expect(api.skipped).toEqual([])
|
||||
if (
|
||||
!Tool.isDefinition(get) ||
|
||||
!Tool.isDefinition(create) ||
|
||||
!Tool.isDefinition(search) ||
|
||||
!Tool.isDefinition(remove)
|
||||
) {
|
||||
if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) {
|
||||
throw new Error("happy-path fixture did not generate every operation")
|
||||
}
|
||||
expect(inputTypeScript(get)).toBe(
|
||||
@@ -241,23 +236,23 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
|
||||
|
||||
const sessionGet = toolAt(result.tools, "v2.session.get")
|
||||
expect(Tool.isDefinition(sessionGet)).toBe(true)
|
||||
if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
|
||||
expect(Tool.isTool(sessionGet)).toBe(true)
|
||||
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
|
||||
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
|
||||
expect(outputTypeScript(sessionGet)).toContain("id: string")
|
||||
expect(outputTypeScript(sessionGet)).toContain("additions: number")
|
||||
|
||||
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
|
||||
expect(Tool.isDefinition(switchAgent)).toBe(true)
|
||||
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
|
||||
expect(Tool.isTool(switchAgent)).toBe(true)
|
||||
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
|
||||
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
|
||||
|
||||
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
|
||||
expect(Tool.isDefinition(instructionPut)).toBe(true)
|
||||
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
|
||||
expect(Tool.isTool(instructionPut)).toBe(true)
|
||||
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
|
||||
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
|
||||
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
|
||||
expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
|
||||
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
|
||||
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
|
||||
@@ -278,9 +273,9 @@ describe("OpenAPI.fromSpec", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
|
||||
expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
|
||||
expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
|
||||
expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true)
|
||||
expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true)
|
||||
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
|
||||
})
|
||||
|
||||
test("synthesizes flat operation IDs from methods and paths", () => {
|
||||
@@ -305,7 +300,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
"deleteUsersById",
|
||||
"getOrganizationsByOrganizationidUsersById",
|
||||
]) {
|
||||
expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
|
||||
expect(Tool.isTool(toolAt(tools, path))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -330,7 +325,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
"test",
|
||||
)
|
||||
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
expect(inputTypeScript(tool)).toBe("{ limit: number }")
|
||||
})
|
||||
|
||||
@@ -358,8 +353,8 @@ describe("OpenAPI.fromSpec", () => {
|
||||
})
|
||||
const search = toolAt(result.tools, "search")
|
||||
|
||||
expect(Tool.isDefinition(search)).toBe(true)
|
||||
if (!Tool.isDefinition(search)) throw new Error("search was not generated")
|
||||
expect(Tool.isTool(search)).toBe(true)
|
||||
if (!Tool.isTool(search)) throw new Error("search was not generated")
|
||||
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
|
||||
const schema: unknown = search.input
|
||||
const input = isRecord(schema) ? schema : {}
|
||||
@@ -397,14 +392,14 @@ describe("OpenAPI.fromSpec", () => {
|
||||
"test",
|
||||
)
|
||||
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
|
||||
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
|
||||
})
|
||||
|
||||
test("projects read-only and write-only properties by schema direction", () => {
|
||||
for (const version of ["3.0.3", "3.1.0"]) {
|
||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
|
||||
throw new Error(`users.create was not generated for OpenAPI ${version}`)
|
||||
}
|
||||
|
||||
@@ -467,7 +462,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||
})
|
||||
@@ -518,7 +513,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const record = isRecord(properties.record) ? properties.record : {}
|
||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
@@ -567,7 +562,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||
})
|
||||
@@ -607,7 +602,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
const node = isRecord(definitions.Node) ? definitions.Node : {}
|
||||
|
||||
@@ -648,7 +643,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
|
||||
|
||||
@@ -686,7 +681,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||
}
|
||||
@@ -725,7 +720,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
|
||||
|
||||
@@ -763,7 +758,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
|
||||
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
|
||||
@@ -807,7 +802,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const record = isRecord(properties.record) ? properties.record : {}
|
||||
|
||||
@@ -836,7 +831,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
|
||||
})
|
||||
@@ -866,7 +861,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
|
||||
})
|
||||
@@ -901,7 +896,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const body = isRecord(properties.body) ? properties.body : {}
|
||||
const allOf = Array.isArray(body.allOf) ? body.allOf : []
|
||||
@@ -923,11 +918,11 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}),
|
||||
)
|
||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
|
||||
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("users.create was not generated")
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
tool
|
||||
.run({
|
||||
.execute({
|
||||
id: "ignored-top-level",
|
||||
generated: "ignored-generated",
|
||||
name: "Ada",
|
||||
@@ -1022,10 +1017,12 @@ describe("OpenAPI.fromSpec", () => {
|
||||
test("serializes deep-object query parameters from the opencode fixture", async () => {
|
||||
const client = recordingClient(() => json({ directory: "/tmp" }))
|
||||
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
|
||||
if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
|
||||
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
|
||||
|
||||
await Effect.runPromise(
|
||||
location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
|
||||
location
|
||||
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
|
||||
.pipe(Effect.provide(client.layer)),
|
||||
)
|
||||
|
||||
const url = new URL(client.requests[0]!.url)
|
||||
@@ -1058,11 +1055,11 @@ describe("OpenAPI.fromSpec", () => {
|
||||
},
|
||||
})
|
||||
const tool = toolAt(result.tools, "items")
|
||||
if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("items was not generated")
|
||||
|
||||
await Effect.runPromise(
|
||||
tool
|
||||
.run({
|
||||
.execute({
|
||||
keys: ["a!", "b*"],
|
||||
tags: ["x", "y"],
|
||||
filter: { state: "open", page: 2 },
|
||||
@@ -1081,9 +1078,9 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(url.searchParams.get("nullable")).toBe("null")
|
||||
expect(url.searchParams.get("constructor")).toBe("safe")
|
||||
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
|
||||
await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
"unsupported nested value",
|
||||
)
|
||||
await expect(
|
||||
Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
|
||||
).rejects.toThrow("unsupported nested value")
|
||||
})
|
||||
|
||||
test("preserves ordered exploded and deep-object query parameters", async () => {
|
||||
@@ -1101,11 +1098,11 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
await Effect.runPromise(
|
||||
tool
|
||||
.run({
|
||||
.execute({
|
||||
tags: ["first value", "second&value"],
|
||||
filter: { state: "open now", page: 2 },
|
||||
location: { directory: "/tmp/a b", workspace: "work&1" },
|
||||
@@ -1116,14 +1113,14 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(client.requests[0]?.url).toBe(
|
||||
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
|
||||
)
|
||||
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
"Parameter 'tags' contains an unsupported nested value.",
|
||||
)
|
||||
await expect(
|
||||
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
||||
Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
||||
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
|
||||
await expect(
|
||||
Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
|
||||
Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
|
||||
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
|
||||
expect(client.requests).toHaveLength(1)
|
||||
})
|
||||
@@ -1203,9 +1200,9 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"getTest",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
|
||||
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{}")
|
||||
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
|
||||
@@ -1240,9 +1237,9 @@ describe("OpenAPI.fromSpec", () => {
|
||||
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
|
||||
if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated")
|
||||
|
||||
await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
|
||||
await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer)))
|
||||
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
|
||||
|
||||
const duplicate = toolAt(
|
||||
@@ -1252,8 +1249,8 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
|
||||
await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated")
|
||||
await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
"multiple credentials",
|
||||
)
|
||||
|
||||
@@ -1278,8 +1275,8 @@ describe("OpenAPI.fromSpec", () => {
|
||||
},
|
||||
})
|
||||
const alternativeTool = toolAt(alternative.tools, "test")
|
||||
if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
|
||||
await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
|
||||
if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated")
|
||||
await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer)))
|
||||
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
|
||||
})
|
||||
|
||||
@@ -1290,9 +1287,9 @@ describe("OpenAPI.fromSpec", () => {
|
||||
servers: [{ url: "https://document.example" }],
|
||||
} satisfies Document
|
||||
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
|
||||
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
|
||||
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
|
||||
|
||||
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
|
||||
@@ -1363,10 +1360,10 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
await expect(
|
||||
Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
|
||||
Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
|
||||
).rejects.toThrow("unsupported nested value")
|
||||
expect(resolutions).toEqual([])
|
||||
expect(client.requests).toEqual([])
|
||||
@@ -1389,33 +1386,33 @@ describe("OpenAPI.fromSpec", () => {
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
|
||||
await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
|
||||
await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
|
||||
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
"Invalid JSON body",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects oversized and malformed JSON responses", async () => {
|
||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
const oversized = recordingClient(
|
||||
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
|
||||
)
|
||||
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
|
||||
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
|
||||
|
||||
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
|
||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
|
||||
"response exceeds 50 MiB",
|
||||
)
|
||||
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
|
||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
|
||||
"returned malformed JSON",
|
||||
)
|
||||
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
|
||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
|
||||
"response exceeds 50 MiB",
|
||||
)
|
||||
})
|
||||
@@ -1428,11 +1425,11 @@ describe("OpenAPI.fromSpec", () => {
|
||||
},
|
||||
})
|
||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
||||
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
|
||||
|
||||
expect(outputTypeScript(tool)).toBe("string | null")
|
||||
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
|
||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
|
||||
})
|
||||
|
||||
test("fails missing required parameters before auth and network", async () => {
|
||||
@@ -1497,13 +1494,13 @@ describe("OpenAPI.fromSpec", () => {
|
||||
const update = toolAt(tools, "things.update")
|
||||
const echo = toolAt(tools, "echo")
|
||||
|
||||
expect(Tool.isDefinition(update)).toBe(true)
|
||||
if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
|
||||
expect(Tool.isTool(update)).toBe(true)
|
||||
if (!Tool.isTool(update)) throw new Error("things.update was not generated")
|
||||
expect(inputTypeScript(update)).toBe(
|
||||
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
|
||||
)
|
||||
expect(Tool.isDefinition(echo)).toBe(true)
|
||||
if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
|
||||
expect(Tool.isTool(echo)).toBe(true)
|
||||
if (!Tool.isTool(echo)) throw new Error("echo was not generated")
|
||||
expect(inputTypeScript(echo)).toBe("{ body: string }")
|
||||
|
||||
const runtime = CodeMode.make({ tools })
|
||||
@@ -1584,13 +1581,13 @@ describe("OpenAPI.fromSpec", () => {
|
||||
|
||||
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
|
||||
const tool = toolAt(tools, `body.${name}`)
|
||||
expect(Tool.isDefinition(tool)).toBe(true)
|
||||
if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
|
||||
expect(Tool.isTool(tool)).toBe(true)
|
||||
if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`)
|
||||
const input = isRecord(tool.input) ? tool.input : {}
|
||||
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
|
||||
}
|
||||
const optional = toolAt(tools, "body.optional")
|
||||
if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
|
||||
if (!Tool.isTool(optional)) throw new Error("body.optional was not generated")
|
||||
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ const echoTool = (trace: Trace) =>
|
||||
description: "Echo an id immediately",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ id }) =>
|
||||
execute: ({ id }) =>
|
||||
Effect.sync(() => {
|
||||
trace.starts.push(id)
|
||||
trace.completed += 1
|
||||
@@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>)
|
||||
description: "Echo an id once its gate opens",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ id }) =>
|
||||
execute: ({ id }) =>
|
||||
Effect.gen(function* () {
|
||||
trace.starts.push(id)
|
||||
trace.active += 1
|
||||
@@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
|
||||
description: "Open the gate for an id",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Boolean,
|
||||
run: ({ id }) => Deferred.succeed(gate(id), undefined),
|
||||
execute: ({ id }) => Deferred.succeed(gate(id), undefined),
|
||||
})
|
||||
|
||||
const pendingTool = (trace: Trace) =>
|
||||
@@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) =>
|
||||
description: "Never settle",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ id }) =>
|
||||
execute: ({ id }) =>
|
||||
Effect.gen(function* () {
|
||||
trace.starts.push(id)
|
||||
trace.active += 1
|
||||
@@ -98,14 +98,14 @@ const failingTool = Tool.make({
|
||||
description: "Always refuse",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.fail(toolError("Lookup refused")),
|
||||
execute: () => Effect.fail(toolError("Lookup refused")),
|
||||
})
|
||||
|
||||
const interruptedTool = Tool.make({
|
||||
description: "Interrupt this call",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.interrupt,
|
||||
execute: () => Effect.interrupt,
|
||||
})
|
||||
|
||||
const completedTool = (trace: Trace) =>
|
||||
@@ -113,7 +113,7 @@ const completedTool = (trace: Trace) =>
|
||||
description: "Return the number of completed calls",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Number,
|
||||
run: () => Effect.succeed(trace.completed),
|
||||
execute: () => Effect.succeed(trace.completed),
|
||||
})
|
||||
|
||||
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
|
||||
@@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) =>
|
||||
description: "Never settle; clean up slowly when interrupted",
|
||||
input: Schema.Struct({ cleanupMs: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ cleanupMs }) =>
|
||||
execute: ({ cleanupMs }) =>
|
||||
Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.andThen(
|
||||
|
||||
@@ -18,7 +18,8 @@ const listIssues = Tool.make({
|
||||
},
|
||||
required: ["owner"],
|
||||
},
|
||||
run: () => Effect.succeed("[]"),
|
||||
output: {},
|
||||
execute: () => Effect.succeed("[]"),
|
||||
})
|
||||
|
||||
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
|
||||
@@ -31,7 +32,7 @@ const lookupOrder = Tool.make({
|
||||
output: Schema.Struct({
|
||||
status: Schema.String.annotate({ description: "Current order status" }),
|
||||
}),
|
||||
run: () => Effect.succeed({ status: "open" }),
|
||||
execute: () => Effect.succeed({ status: "open" }),
|
||||
})
|
||||
|
||||
describe("pretty signature rendering", () => {
|
||||
@@ -261,7 +262,7 @@ describe("non-identifier property names render as quoted keys", () => {
|
||||
properties: { "content-type": { type: "string" } },
|
||||
required: ["content-type"],
|
||||
} as const,
|
||||
run: () => Effect.succeed({ "content-type": "text/plain" }),
|
||||
execute: () => Effect.succeed({ "content-type": "text/plain" }),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
|
||||
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
|
||||
@@ -272,7 +273,7 @@ describe("non-identifier property names render as quoted keys", () => {
|
||||
const tool = Tool.make({
|
||||
description: "Schema tool with awkward field names",
|
||||
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
|
||||
run: () => Effect.succeed(null),
|
||||
execute: () => Effect.succeed(null),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
|
||||
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
|
||||
@@ -306,7 +307,7 @@ describe("union schemas render every alternative", () => {
|
||||
},
|
||||
} as const,
|
||||
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
|
||||
run: () => Effect.succeed(1),
|
||||
execute: () => Effect.succeed(1),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
|
||||
expect(outputTypeScript(tool)).toBe("number | boolean")
|
||||
@@ -417,7 +418,8 @@ describe("non-identifier tool paths", () => {
|
||||
},
|
||||
required: ["query", "libraryName"],
|
||||
} as const,
|
||||
run: () => Effect.succeed("/reactjs/react.dev"),
|
||||
output: {},
|
||||
execute: () => Effect.succeed("/reactjs/react.dev"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
|
||||
@@ -329,7 +329,7 @@ describe("RegExp", () => {
|
||||
description: "Decorate a string",
|
||||
input: Schema.String,
|
||||
output: Schema.String,
|
||||
run: (input) => Effect.succeed(`[${input}]`),
|
||||
execute: (input) => Effect.succeed(`[${input}]`),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
@@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
|
||||
const capture = Tool.make({
|
||||
description: "Capture the exact input the host receives",
|
||||
input: { type: "object" },
|
||||
run: (input) =>
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return "ok"
|
||||
|
||||
@@ -7,7 +7,7 @@ const echo = (description: string, result: string) =>
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed(result),
|
||||
execute: () => Effect.succeed(result),
|
||||
})
|
||||
|
||||
const value = async (runtime: CodeMode.Runtime, code: string) => {
|
||||
@@ -88,7 +88,7 @@ describe("callable namespaces", () => {
|
||||
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
|
||||
})
|
||||
|
||||
test("a namespace without its own definition stays non-callable", async () => {
|
||||
test("a namespace without its own tool stays non-callable", async () => {
|
||||
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
|
||||
const diagnostic = await failure(nested, `return await tools.issues({})`)
|
||||
expect(diagnostic.kind).toBe("UnknownTool")
|
||||
@@ -106,7 +106,7 @@ describe("blocked member names on tool paths", () => {
|
||||
})
|
||||
|
||||
test("tools may use blocked member names because path segments never touch real properties", async () => {
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["prototype", "issues.constructor", "nested.__proto__"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
|
||||
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
|
||||
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
|
||||
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
|
||||
@@ -114,9 +114,9 @@ describe("blocked member names on tool paths", () => {
|
||||
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
|
||||
})
|
||||
|
||||
test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => {
|
||||
test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
|
||||
const poisoned = CodeMode.make({
|
||||
tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
|
||||
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
|
||||
})
|
||||
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
|
||||
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
|
||||
@@ -138,7 +138,7 @@ describe("empty segments", () => {
|
||||
})
|
||||
|
||||
describe("canonical path collisions", () => {
|
||||
test("the last definition supplied for a canonical path wins", async () => {
|
||||
test("the last tool supplied for a canonical path wins", async () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
|
||||
})
|
||||
@@ -155,8 +155,7 @@ describe("canonical path collisions", () => {
|
||||
"issues.close": echo("Close issue", "closed"),
|
||||
},
|
||||
})
|
||||
// Catalog order follows first appearance of each canonical path.
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.list", "issues.get", "issues.close"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
||||
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -91,7 +91,7 @@
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
|
||||
@@ -422,7 +422,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
|
||||
presencePenalty: request.generation?.presencePenalty,
|
||||
frequencyPenalty: request.generation?.frequencyPenalty,
|
||||
seed: request.generation?.seed,
|
||||
responseFormat: responseFormat(request),
|
||||
tools: request.tools.map(tool),
|
||||
toolChoice: toolChoice(request.toolChoice),
|
||||
headers: request.http?.headers,
|
||||
@@ -527,12 +526,6 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice
|
||||
return { type: input.type }
|
||||
}
|
||||
|
||||
function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] {
|
||||
if (request.responseFormat?.type === "json")
|
||||
return { type: "json", schema: request.responseFormat.schema as JSONSchema7 }
|
||||
if (request.responseFormat) return { type: "text" }
|
||||
}
|
||||
|
||||
function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined {
|
||||
if (!input) return undefined
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||
@@ -666,12 +659,12 @@ function streamPartEvents(
|
||||
return Effect.succeed([
|
||||
LLMEvent.stepFinish({
|
||||
index: state.step++,
|
||||
reason: finishReason(event.finishReason),
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
usage: usage(event.usage),
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: finishReason(event.finishReason),
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
usage: usage(event.usage),
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
|
||||
@@ -4,19 +4,17 @@ import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
|
||||
import { Tools } from "./tool/tools"
|
||||
import type { Any, Registration } from "./tool/tool"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: AnyTool
|
||||
readonly tool?: Any
|
||||
readonly instructions?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
registrations: ReadonlyArray<Registration & { readonly key: string }>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
}
|
||||
@@ -26,29 +24,28 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const local = new Map<
|
||||
string,
|
||||
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
|
||||
>()
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("CodeMode.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.namespace)
|
||||
if (entries.length === 0) return
|
||||
register: Effect.fn("CodeMode.register")(function* (registrations) {
|
||||
if (registrations.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
|
||||
for (const registration of registrations)
|
||||
local.set(registration.key, [
|
||||
...(local.get(registration.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration,
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||
else local.delete(entry.key)
|
||||
for (const registration of registrations) {
|
||||
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(registration.key, remaining)
|
||||
else local.delete(registration.key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -61,7 +58,7 @@ const layer = Layer.effect(
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
|
||||
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeMode } from "../codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Instructions } from "../instructions/index"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(Schema.String)
|
||||
const render = {
|
||||
initial: (current: string) => current,
|
||||
changed: (_previous: string, current: string) =>
|
||||
[
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
current,
|
||||
].join("\n\n"),
|
||||
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeModeInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("CodeModeInstructions.load")(function* (selection) {
|
||||
const instructions = selection.info
|
||||
? (yield* codeMode.materialize(selection.info.permissions)).instructions
|
||||
: undefined
|
||||
return Instructions.make({
|
||||
key: Instructions.Key.make("core/codemode"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.succeed(instructions ?? Instructions.removed),
|
||||
render: {
|
||||
initial: (current) => current,
|
||||
changed: (_previous, current) =>
|
||||
[
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
current,
|
||||
].join("\n\n"),
|
||||
removed: () =>
|
||||
"Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CodeMode.node] })
|
||||
export const make = (content?: string): Instructions.Instructions =>
|
||||
Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(content ?? Instructions.removed),
|
||||
render,
|
||||
})
|
||||
|
||||
+1
@@ -56,5 +56,6 @@ export const migrations = (
|
||||
import("./migration/20260710025429_instruction_sync"),
|
||||
import("./migration/20260716020354_kv"),
|
||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||
import("./migration/20260722170000_canonical_tool_results"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
|
||||
const resultOf = (state: Record<string, unknown>) =>
|
||||
isObject(state.result) && "value" in state.result ? state.result.value : state.result
|
||||
const metadataOf = (state: Record<string, unknown>) => {
|
||||
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
|
||||
return { metadata: state.structured }
|
||||
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
|
||||
}
|
||||
const completedContent = (state: Record<string, unknown>) => {
|
||||
const preserved = contentOf(state)
|
||||
if (preserved.length > 0) return preserved
|
||||
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time rewrite of projected tool rows into the canonical result shape:
|
||||
* terminal states store model content plus optional metadata; the generic
|
||||
* `structured` and `result` fields disappear. Provider-hosted result payloads
|
||||
* move into provider-owned result state so hosted continuation survives.
|
||||
* Pre-release durable event versions are intentionally left untouched.
|
||||
*/
|
||||
export default {
|
||||
id: "20260722170000_canonical_tool_results",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Keyset-paginated batches keep memory bounded: production databases hold
|
||||
// gigabytes of assistant rows, and materializing them all at once was
|
||||
// measured at a ~5GB RSS spike.
|
||||
let cursor = ""
|
||||
while (true) {
|
||||
const messages = yield* tx.all<{ id: string; data: string }>(
|
||||
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
|
||||
)
|
||||
if (messages.length === 0) break
|
||||
cursor = messages[messages.length - 1].id
|
||||
yield* rewrite(tx, messages)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
|
||||
return Effect.gen(function* () {
|
||||
for (const row of messages) {
|
||||
// A row that never decoded is skipped rather than failing the whole
|
||||
// migration on every startup; it was equally unreadable before.
|
||||
const decoded = decodeJson(row.data)
|
||||
if (decoded._tag === "None") {
|
||||
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
|
||||
continue
|
||||
}
|
||||
const data = object(decoded.value)
|
||||
if (!Array.isArray(data.content)) continue
|
||||
let changed = false
|
||||
const content = data.content.map((part) => {
|
||||
const tool = object(part)
|
||||
if (tool.type !== "tool" || !isObject(tool.state)) return part
|
||||
const state = tool.state
|
||||
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
|
||||
if (!("structured" in state) && !("result" in state)) return part
|
||||
changed = true
|
||||
if (state.status === "running")
|
||||
return {
|
||||
...tool,
|
||||
state: {
|
||||
status: "running",
|
||||
input: object(state.input),
|
||||
metadata: object(state.structured),
|
||||
},
|
||||
}
|
||||
// Hosted payloads are irreducible provider replay state; keep them under
|
||||
// the provider-owned result state instead of a generic result field.
|
||||
const hosted =
|
||||
tool.executed === true && isObject(state.result) && "value" in state.result
|
||||
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
|
||||
: {}
|
||||
const preserved = contentOf(state)
|
||||
if (state.status === "completed")
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: object(state.input),
|
||||
content: completedContent(state),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "error",
|
||||
input: object(state.input),
|
||||
error: state.error,
|
||||
...(preserved.length > 0 ? { content: preserved } : {}),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
})
|
||||
if (!changed) continue
|
||||
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CodeMode } from "./codemode"
|
||||
import { CodeModeInstructions } from "./codemode/instructions"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -82,7 +81,6 @@ const locationServiceNodes = [
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
CodeModeInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
|
||||
@@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
),
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) =>
|
||||
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
list: () => response(catalog.model.available()),
|
||||
default: () => response(catalog.model.default()),
|
||||
},
|
||||
@@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
Effect.gen(function* () {
|
||||
const registrations: Array<{
|
||||
readonly name: string
|
||||
readonly tool: Tool.AnyTool
|
||||
readonly tool: Tool.Any
|
||||
readonly options?: Tool.RegisterOptions
|
||||
}> = []
|
||||
yield* Effect.sync(() =>
|
||||
@@ -395,6 +394,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
}
|
||||
return toolHooks.hook.after((event) => {
|
||||
// Decode first so plugin mutations cannot alias the canonical outcome.
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
@@ -402,18 +402,30 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
messageID: event.messageID,
|
||||
callID: event.callID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
Effect.tap(() => {
|
||||
const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output)
|
||||
if (decoded._tag === "None")
|
||||
return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool })
|
||||
if (decoded.value.status !== event.status)
|
||||
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
|
||||
return Effect.sync(() => {
|
||||
if (event.status === "completed" && decoded.value.status === "completed") {
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
return
|
||||
}
|
||||
if (event.status === "error" && decoded.value.status === "error") {
|
||||
event.error = decoded.value.error
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
|
||||
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
|
||||
import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
@@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) {
|
||||
register(
|
||||
host.tool.transform((draft) =>
|
||||
callback({
|
||||
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
|
||||
add: (name: string, tool: Any, options?: RegisterOptions) =>
|
||||
draft.add(name, fromPromiseTool(tool), options),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown {
|
||||
return wire(value)
|
||||
}
|
||||
|
||||
function fromPromiseTool(tool: AnyTool) {
|
||||
if ("jsonSchema" in tool)
|
||||
return Tool.make({
|
||||
...tool,
|
||||
execute: (input, context) =>
|
||||
Effect.promise(() =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
return Tool.make({
|
||||
function fromPromiseTool(tool: Any): Tool.Any {
|
||||
return {
|
||||
...tool,
|
||||
execute: (input, context) =>
|
||||
Effect.promise(() =>
|
||||
@@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) {
|
||||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionContext from "./context"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { Database } from "../database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { InstructionDiscovery } from "../instruction-discovery"
|
||||
@@ -12,7 +13,7 @@ import { McpInstructions } from "../mcp/instructions"
|
||||
import { PluginSupervisor } from "../plugin/supervisor"
|
||||
import { ReferenceInstructions } from "../reference/instructions"
|
||||
import { SkillInstructions } from "../skill/instructions"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { AgentNotFoundError } from "./error"
|
||||
import { SessionHistory } from "./history"
|
||||
import { InstructionEntry } from "./instruction-entry"
|
||||
@@ -25,6 +26,7 @@ export interface Selection {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info }
|
||||
readonly instructions: Instructions.Instructions
|
||||
readonly toolSet: ToolRegistry.ToolSet
|
||||
}
|
||||
|
||||
export interface Loaded {
|
||||
@@ -33,15 +35,17 @@ export interface Loaded {
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly toolSet: ToolRegistry.ToolSet
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves model-request state in two phases: `select` fixes the Session,
|
||||
* agent, and instruction sources; `load` adds the model and active history for
|
||||
* that selection. This module does not build or execute the model request.
|
||||
* agent, instruction sources, and tool snapshot; `load` adds the model and
|
||||
* active history for that selection. This module does not build or execute the
|
||||
* model request.
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Selects the Session, agent, and instruction sources used by subsequent work. */
|
||||
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
|
||||
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
/** Resolves the model and active history for that selection. */
|
||||
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
|
||||
@@ -55,7 +59,6 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const builtins = yield* InstructionBuiltIns.Service
|
||||
const codeModeInstructions = yield* CodeModeInstructions.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
@@ -66,6 +69,7 @@ const layer = Layer.effect(
|
||||
const referenceInstructions = yield* ReferenceInstructions.Service
|
||||
const skillInstructions = yield* SkillInstructions.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -76,19 +80,32 @@ const layer = Layer.effect(
|
||||
yield* plugins.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const instructions = yield* Effect.all(
|
||||
[
|
||||
builtins.load(sessionID),
|
||||
codeModeInstructions.load(agent),
|
||||
discovery.load(),
|
||||
skillInstructions.load(agent),
|
||||
referenceInstructions.load(),
|
||||
mcpInstructions.load(agent),
|
||||
entries.load(sessionID),
|
||||
],
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
toolSet: registry.snapshot(agent.info.permissions),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
references: referenceInstructions.load(),
|
||||
mcp: mcpInstructions.load(agent),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map(Instructions.combine))
|
||||
return { session, agent: { ...agent, info: agent.info }, instructions }
|
||||
)
|
||||
return {
|
||||
session,
|
||||
agent: { ...agent, info: agent.info },
|
||||
instructions: Instructions.combine([
|
||||
loaded.builtins,
|
||||
CodeModeInstructions.make(loaded.toolSet.codeModeInstructions),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
loaded.references,
|
||||
loaded.mcp,
|
||||
loaded.entries,
|
||||
]),
|
||||
toolSet: loaded.toolSet,
|
||||
}
|
||||
})
|
||||
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
@@ -100,6 +117,7 @@ const layer = Layer.effect(
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.entries.map((entry) => entry.message),
|
||||
toolSet: selection.toolSet,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,7 +130,6 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
AgentV2.node,
|
||||
CodeModeInstructions.node,
|
||||
Database.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
@@ -124,5 +141,6 @@ export const node = makeLocationNode({
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
SkillInstructions.node,
|
||||
ToolRegistry.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import { SessionGenerate } from "./generate"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
@@ -24,7 +23,6 @@ export const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const app = yield* App.Metadata
|
||||
|
||||
return SessionGenerate.Service.of({
|
||||
@@ -36,8 +34,8 @@ export const layer = Layer.effect(
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
|
||||
? selection.session.id.slice(4)
|
||||
: selection.session.id
|
||||
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
|
||||
const toolDefinitions = executableTools.definitions
|
||||
const toolSet = selection.toolSet
|
||||
const toolDefinitions = toolSet.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
sessionID: selection.session.id,
|
||||
@@ -52,7 +50,10 @@ export const layer = Layer.effect(
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
tools: Object.fromEntries(
|
||||
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
||||
toolDefinitions.map((tool) => [
|
||||
tool.name,
|
||||
{ description: tool.description, input: { ...tool.inputSchema } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
@@ -74,7 +75,6 @@ export const layer = Layer.effect(
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
toolChoice: "none",
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
@@ -87,13 +87,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [
|
||||
SessionContext.node,
|
||||
Database.node,
|
||||
PluginHooks.node,
|
||||
SessionRunnerModel.node,
|
||||
ToolRegistry.node,
|
||||
App.node,
|
||||
llmClient,
|
||||
],
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -355,8 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
SessionMessage.ToolStateRunning.make({
|
||||
status: "running",
|
||||
input: event.data.input,
|
||||
structured: {},
|
||||
content: [],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -366,11 +365,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
match.state.metadata = event.data.metadata
|
||||
}
|
||||
})
|
||||
},
|
||||
// Terminal tool events are self-contained; projection is a direct copy and
|
||||
// never reaches into ephemeral progress history.
|
||||
"session.tool.success": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
@@ -382,9 +382,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
content: event.data.content,
|
||||
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -402,9 +401,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
|
||||
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
|
||||
result: event.data.result,
|
||||
...(event.data.content === undefined ? {} : { content: event.data.content }),
|
||||
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
type ToolCallResolution =
|
||||
| { readonly type: "reject"; readonly error: SessionError.Error }
|
||||
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
|
||||
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly resolveToolCall: (name: string) => ToolCallResolution
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: ToolRegistry.ToolSet["execute"]
|
||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
||||
readonly stepLimitReached: boolean
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
@@ -84,7 +86,6 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const app = yield* App.Metadata
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
@@ -94,14 +95,16 @@ export const layer = Layer.effect(
|
||||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
||||
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const toolSet = input.context.toolSet
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const toolDefinitions = executableTools?.definitions ?? []
|
||||
const toolDefinitions = toolSet.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
@@ -131,22 +134,23 @@ export const layer = Layer.effect(
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const resolveToolCall = (name: string): ToolCallResolution => {
|
||||
if (!executableTools)
|
||||
return {
|
||||
type: "reject",
|
||||
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return Effect.succeed({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
|
||||
}
|
||||
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
|
||||
return {
|
||||
type: "reject",
|
||||
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
|
||||
}
|
||||
return { type: "settle", settle: executableTools.settle }
|
||||
})
|
||||
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
|
||||
return Effect.succeed({
|
||||
status: "error",
|
||||
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
|
||||
})
|
||||
return toolSet.execute(executeInput)
|
||||
}
|
||||
return {
|
||||
request,
|
||||
resolveToolCall,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,5 +161,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, ToolRegistry.node, App.node],
|
||||
deps: [PluginHooks.node, App.node],
|
||||
})
|
||||
|
||||
@@ -130,7 +130,7 @@ const layer = Layer.effect(
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
@@ -144,21 +144,18 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
return
|
||||
}
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
const tool = prepared.resolveToolCall(event.name)
|
||||
if (tool.type === "reject") {
|
||||
yield* serialized(publisher.failUnsettledTools(tool.error))
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
// Unavailable calls fail individually through the same execution seam;
|
||||
// continuation depends only on remaining Step allowance.
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
tool.settle({
|
||||
prepared.executeTool({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
@@ -166,17 +163,7 @@ const layer = Layer.effect(
|
||||
progress: (update) => serialized(publisher.progress(event.id, update)),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.error,
|
||||
),
|
||||
),
|
||||
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import type { ToolRegistry } from "../../tool/registry"
|
||||
|
||||
type Input = {
|
||||
@@ -25,24 +26,13 @@ type Input = {
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
const message = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
const content = Tool.nonEmpty(result.value)
|
||||
if (content !== undefined) return content
|
||||
}
|
||||
}
|
||||
|
||||
type SettledOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||
| { readonly error: SessionError.Error }
|
||||
|
||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
/** Persist one step without executing tools or starting a continuation step. */
|
||||
@@ -58,14 +48,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
progress?: ToolRegistry.Progress
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
|
||||
if (!tool.progress) return {}
|
||||
const first = tool.progress.content[0]
|
||||
return {
|
||||
...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }),
|
||||
metadata: tool.progress.structured,
|
||||
}
|
||||
}
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
@@ -74,7 +58,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
| undefined
|
||||
@@ -254,11 +238,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (
|
||||
tool.settled ||
|
||||
(mode === "hosted" && !tool.providerExecuted) ||
|
||||
(mode === "uncalled" && tool.called)
|
||||
)
|
||||
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
|
||||
continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
@@ -310,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
@@ -409,26 +389,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return
|
||||
}
|
||||
case "tool-result": {
|
||||
// Provider-hosted results only; local executions publish through `toolExecution`.
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||
if (tool.settled) {
|
||||
// A late error result is a benign straggler (e.g. after an abort
|
||||
// sweep); a late success would mean double execution, so it dies.
|
||||
if (event.result.type === "error") return
|
||||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||
}
|
||||
tool.settled = true
|
||||
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if ("error" in result) {
|
||||
if (event.result.type === "error") {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: result.error,
|
||||
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
result: event.result,
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
@@ -438,8 +419,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
...result,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
content: hostedContent(event.result),
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
@@ -469,8 +449,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason === "content-filter") {
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
return
|
||||
@@ -489,19 +469,63 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
|
||||
const current = { structured: { ...update.structured }, content: [...update.content] }
|
||||
tool.progress = current
|
||||
tool.progress = update
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
...current,
|
||||
metadata: update,
|
||||
})
|
||||
})
|
||||
|
||||
/** Publishes one canonical terminal event for a locally executed tool call. */
|
||||
const toolExecution = Effect.fnUntraced(function* (
|
||||
callID: string,
|
||||
name: string,
|
||||
execution: ToolRegistry.ToolOutcome,
|
||||
) {
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
|
||||
if (tool.name !== name)
|
||||
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
|
||||
if (tool.settled) {
|
||||
if (execution.status === "error") return
|
||||
return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
|
||||
}
|
||||
tool.settled = true
|
||||
if (execution.status === "completed") {
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
content: execution.content,
|
||||
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
return
|
||||
}
|
||||
// An execution-provided snapshot wins; otherwise fall back to retained progress.
|
||||
const snapshot =
|
||||
execution.content !== undefined || execution.metadata !== undefined
|
||||
? {
|
||||
...(execution.content === undefined ? {} : { content: execution.content }),
|
||||
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
|
||||
}
|
||||
: failureSnapshot(tool)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error: execution.error,
|
||||
...snapshot,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
publish,
|
||||
progress,
|
||||
toolExecution,
|
||||
flush,
|
||||
failAssistant,
|
||||
publishStepFailure,
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolOutput,
|
||||
ToolResultPart,
|
||||
type ContentPart,
|
||||
type ProviderMetadata,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { ModelV2 } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
@@ -90,15 +83,15 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
|
||||
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
|
||||
if (tool.state.status === "completed") {
|
||||
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
||||
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
||||
const result =
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
||||
const content = tool.state.content
|
||||
const single = content.length === 1 ? content[0] : undefined
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result,
|
||||
result:
|
||||
single?.type === "text"
|
||||
? { type: "text" as const, value: single.text }
|
||||
: { type: "content" as const, value: content },
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
@@ -107,10 +100,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result:
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
||||
result: { error: tool.state.error, content: tool.state.content ?? [] },
|
||||
resultType: "error",
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
@@ -119,8 +109,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||
const sameProvider = String(message.model.providerID) === String(model.providerID)
|
||||
const sameModel = sameProvider && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
@@ -138,19 +128,21 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
: []
|
||||
const reuseToolProviderMetadata =
|
||||
reuseProviderMetadata ||
|
||||
(sameModel &&
|
||||
item.executed === true &&
|
||||
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
|
||||
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
// Hosted result payloads are provider-format state, not model state:
|
||||
// replay must survive a model switch within the same provider.
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseToolProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
: sameProvider && item.executed === true && item.providerResultState !== undefined
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState)
|
||||
: undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
})
|
||||
|
||||
@@ -39,8 +39,13 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
}
|
||||
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
|
||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Failure) {
|
||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||
// The canonical error is the sole model-visible representation, so a cause
|
||||
// with no message must not erase the tool's curated failure message.
|
||||
const unwrapped = toSessionError(cause.error)
|
||||
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
|
||||
}
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import type { ToolOutput } from "@opencode-ai/ai"
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
@@ -19,11 +19,11 @@ export const MANAGED_DIRECTORY = "tool-output"
|
||||
export interface BoundInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly callID: string
|
||||
readonly output: ToolOutput
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
}
|
||||
|
||||
export interface BoundResult {
|
||||
readonly output: ToolOutput
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
readonly outputPaths: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
@@ -137,21 +137,14 @@ const layer = Layer.effect(
|
||||
|
||||
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
|
||||
const outputLimits = yield* limits()
|
||||
const media = input.output.content.filter((item) => item.type === "file")
|
||||
const text = input.output.content.filter((item) => item.type === "text")
|
||||
const contextual =
|
||||
input.output.content.length === 0
|
||||
? yield* Effect.try({
|
||||
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
|
||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||
})
|
||||
: text.map((item) => item.text).join("")
|
||||
const media = input.content.filter((item) => item.type === "file")
|
||||
const contextual = input.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("")
|
||||
if (
|
||||
lineCount(contextual) <= outputLimits.maxLines &&
|
||||
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
||||
)
|
||||
return {
|
||||
output: input.output,
|
||||
content: input.content,
|
||||
outputPaths: [],
|
||||
}
|
||||
|
||||
@@ -159,16 +152,13 @@ const layer = Layer.effect(
|
||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||
|
||||
return {
|
||||
output: {
|
||||
structured: input.output.structured,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
],
|
||||
outputPaths: [outputPath],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# Core Tool Architecture
|
||||
|
||||
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
|
||||
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
|
||||
|
||||
## Representations
|
||||
|
||||
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type.
|
||||
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
## Construction
|
||||
|
||||
Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally.
|
||||
Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action.
|
||||
|
||||
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
|
||||
|
||||
```ts
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,13 +42,13 @@ Registrations are scoped:
|
||||
|
||||
## Permissions
|
||||
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
|
||||
|
||||
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
|
||||
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
|
||||
|
||||
@@ -97,15 +97,11 @@ export const Plugin = {
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.withPermission(
|
||||
Tool.make({
|
||||
Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
@@ -207,12 +203,16 @@ export const Plugin = {
|
||||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
})
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output, input.oldString, input.newString),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export * as ExecuteTool from "./execute"
|
||||
export type { Registration } from "./tool"
|
||||
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import { ToolOutput } from "@opencode-ai/ai"
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { definition, make, settle, type AnyTool } from "./tool"
|
||||
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({
|
||||
const ExecuteCall = Schema.Struct({
|
||||
tool: Schema.String,
|
||||
status: Schema.Literals(["running", "completed", "error"]),
|
||||
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
||||
})
|
||||
|
||||
type ExecuteCall = typeof ExecuteCall.Type
|
||||
|
||||
const ExecuteMetadata = Schema.Struct({
|
||||
toolCalls: Schema.Array(ExecuteCall),
|
||||
error: Schema.optionalKey(Schema.Literal(true)),
|
||||
})
|
||||
|
||||
const ExecuteOutput = Schema.Struct({
|
||||
output: Schema.String,
|
||||
toolCalls: Schema.Array(ExecuteCall),
|
||||
@@ -36,12 +32,6 @@ type CollectedFiles = {
|
||||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
export interface Registration {
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime through { code }.",
|
||||
@@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
description,
|
||||
input: CodeMode.Input,
|
||||
output: ExecuteOutput,
|
||||
structured: ExecuteMetadata,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
toolCalls: output.toolCalls,
|
||||
...(output.error ? { error: true as const } : {}),
|
||||
}),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "text" as const, text: output.output },
|
||||
...output.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
data: file.data,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
],
|
||||
execute: ({ code }, context) =>
|
||||
Effect.gen(function* () {
|
||||
const callIndex = yield* Ref.make(0)
|
||||
@@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
(name, registration, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
const output = yield* settle(
|
||||
registration.tool,
|
||||
{ type: "tool-call", id: context.callID, name, input },
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: context.progress,
|
||||
},
|
||||
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(output)
|
||||
const executed = yield* execute(registration.tool, input, {
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: context.progress,
|
||||
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(executed.content)
|
||||
if (outputFileParts.length > 0)
|
||||
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||
return output.structured
|
||||
return executed.output
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) =>
|
||||
@@ -126,7 +98,30 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
.toSorted((left, right) => left.index - right.index)
|
||||
.flatMap((item) => item.files)
|
||||
const output = formatResult(result)
|
||||
return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
|
||||
const value: typeof ExecuteOutput.Type = {
|
||||
output,
|
||||
toolCalls,
|
||||
files: collected,
|
||||
...(result.ok ? {} : { error: true }),
|
||||
}
|
||||
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
|
||||
content.push(
|
||||
...value.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
data: file.data,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
)
|
||||
const metadata: Metadata = {
|
||||
toolCalls: value.toolCalls,
|
||||
...(value.error ? { error: true } : {}),
|
||||
}
|
||||
return {
|
||||
output: value,
|
||||
content,
|
||||
metadata,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -137,28 +132,30 @@ export const instructions = (registrations: ReadonlyMap<string, Registration>) =
|
||||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Registration>,
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Definition<never>> = {}
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
const child = toLLMDefinition(name, registration.tool)
|
||||
const path =
|
||||
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema,
|
||||
run: (input) => invoke(name, registration, input),
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
})
|
||||
}
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
|
||||
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
|
||||
if (Object.keys(input).length === 0) return
|
||||
return input as Record<string, unknown>
|
||||
return input as Record<string, typeof Schema.Json.Type>
|
||||
}
|
||||
|
||||
function formatResult(result: CodeMode.Result) {
|
||||
@@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
}
|
||||
|
||||
function outputFiles(output: ToolOutput): Array<typeof ExecuteFile.Type> {
|
||||
return output.content.flatMap((part) => {
|
||||
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
|
||||
return content.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const prefix = `data:${part.mime};base64,`
|
||||
if (!part.uri.startsWith(prefix)) return []
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { NonNegativeInt, RelativePath } from "../schema"
|
||||
import { RelativePath } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
@@ -25,9 +25,6 @@ export const Input = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Entry)
|
||||
const StructuredOutput = Schema.Struct({
|
||||
count: NonNegativeInt,
|
||||
})
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search results into the concise line-oriented output models expect. */
|
||||
@@ -54,16 +51,6 @@ export const Plugin = {
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -104,6 +91,13 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
metadata: { count: output.length },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { NonNegativeInt, RelativePath } from "../schema"
|
||||
import { RelativePath } from "../schema"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "grep"
|
||||
@@ -30,9 +30,6 @@ export const Input = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Match)
|
||||
const StructuredOutput = Schema.Struct({
|
||||
matches: NonNegativeInt,
|
||||
})
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search matches into the familiar concise model output. */
|
||||
@@ -68,19 +65,6 @@ export const Plugin = {
|
||||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -135,6 +119,16 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
metadata: { matches: output.length },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
export * as ToolHooks from "./hooks"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { State } from "../state"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai"
|
||||
import type { Tool } from "./tool"
|
||||
|
||||
export interface BeforeEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
input: unknown
|
||||
}
|
||||
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
|
||||
|
||||
export interface AfterEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
/** The canonical execution outcome. Hooks never observe the raw domain output. */
|
||||
export type AfterEvent = Tool.ToolExecuteAfterEvent
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
|
||||
@@ -32,86 +32,90 @@ export const layer = Layer.effectDiscard(
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
tools: Record<string, Tool.Any>
|
||||
codemode: boolean
|
||||
}
|
||||
>()
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
group.tools[tool.name] = Tool.withPermission(
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
},
|
||||
group.tools[tool.name] = Tool.make({
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
structured: result.structured ?? (text === "" ? null : text),
|
||||
content,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
}),
|
||||
name(tool.server, tool.name),
|
||||
)
|
||||
),
|
||||
})
|
||||
groups.set(tool.server, group)
|
||||
}
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
).pipe(Scope.provide(next), Effect.orDie)
|
||||
yield* tools
|
||||
.registerBatch(
|
||||
Array.from(groups, ([server, group]) => ({
|
||||
tools: group.tools,
|
||||
options: { namespace: namespace(server), codemode: group.codemode },
|
||||
})),
|
||||
)
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
@@ -75,20 +76,17 @@ export const Plugin = {
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.withPermission(
|
||||
Tool.make({
|
||||
Tool.make({
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string, error?: unknown) => {
|
||||
const prefix =
|
||||
applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix, error })
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -103,11 +101,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
return yield* new ToolFailure({ message: "patch verification failed: no hunks found" })
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
@@ -147,7 +141,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -163,7 +157,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -177,7 +171,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -186,7 +180,8 @@ export const Plugin = {
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
@@ -213,7 +208,13 @@ export const Plugin = {
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error))))
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
@@ -236,12 +237,16 @@ export const Plugin = {
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
@@ -250,7 +255,11 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs.remove(change.target.canonical)
|
||||
yield* fs
|
||||
.remove(change.target.canonical)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
@@ -259,8 +268,15 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
yield* fs.writeWithDirs(change.moveTarget.canonical, change.content)
|
||||
yield* fs.remove(change.target.canonical)
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs.remove(change.target.canonical).pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
@@ -268,22 +284,33 @@ export const Plugin = {
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs.writeWithDirs(change.target.canonical, change.content)
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
}).pipe(Effect.mapError((error) => fail(change.path, error))),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
@@ -303,6 +330,14 @@ export const Plugin = {
|
||||
}),
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof PlatformError) {
|
||||
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||
return error.reason.description ?? error.reason.message
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user