Compare commits

..

8 Commits

Author SHA1 Message Date
Aiden Cline 027ce5c0e1 fix(ai): merge parallel gemini tool results into one turn
Gemini requires all responses to a parallel function-call batch to
appear as functionResponse parts in a single user turn. Consecutive
tool messages each lowered to their own user turn, producing an
invalid continuation that providers reject.

Consecutive tool results now join the open function-response turn,
matching the existing rule that keeps system updates out of it.

Refs #43478
2026-08-21 01:37:15 -05:00
Major Hayden 5964c5b897 fix(ai): preserve Vertex Anthropic tool continuations
- fold terminal system updates into Vertex local tool-result turns

- preserve native system updates for direct Anthropic and ordinary Vertex requests

Refs #43478

Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-19 23:21:07 -05:00
Major Hayden 9aba0bc4fb fix(ai): isolate Gemini function-response turns
- keep chronological system updates out of the Gemini turn that answers a function call

- cover repeated updates following a tool result

Refs #43478

Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-19 23:21:06 -05:00
opencode-agent[bot] dbc7d0ee09 chore: update nix node_modules hashes 2026-08-20 04:01:43 +00:00
Aiden Cline 08f26a2d2e feat(ai): support Responses request options (#43360) 2026-08-19 22:45:26 -05:00
Kit Langton a51622a0e7 chore: upgrade Effect to rc.110 (#43567) 2026-08-20 03:43:23 +00:00
Kit Langton 20ff543ff2 fix(tui): honest reconnect overlay copy without a managed service (#43561) 2026-08-20 03:33:57 +00:00
Kit Langton f43474043a feat(core): acknowledge session interruption immediately (#43552) 2026-08-20 03:16:32 +00:00
31 changed files with 465 additions and 325 deletions
+27 -15
View File
@@ -190,7 +190,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.107",
"effect": "4.0.0-rc.110",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -522,7 +522,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-rc.110",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -1084,10 +1084,10 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.107",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1124,7 +1124,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.107",
"effect": "4.0.0-rc.110",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1571,13 +1571,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.107", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-beta.107" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-WxR3OEcwVtckNYGxvERA4kiS8cb2B46lSWxQw8P6dCCzW0j0VC7hkWyzryJ16MVXfI/5xQHS3r5j9mud+JVvsg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.110", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.110" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-8Uum1ikIAK2DjATeLseS71HZKVI/dduFYypvMhiO68RTli1xq9yaoTI1Y0IThPn2sI0i86ZdpFaKQx1hdw2tWw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.107", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.107", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107", "ioredis": ">=5.7.0 <6.0.0" } }, "sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.110", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.110", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110", "redis": ">=5.0.0 <7.0.0" } }, "sha512-poj6VxTc2kRDowUHgjGXAZL2nWHTyGi/18607jK+pV9A9CH/wqZ4NeYj38rij95j5SiZ3U7Y1iOsk2Vfnr4GEw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.107", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.110", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.107", "", { "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-BuSSCUoXz6JSR30wT4Y0ukmJKvIFhm/gROwEeA0nLO5QYf33CdTunwX+q35+/MNOOLT8jL3xaDsz5R5aAF1vYQ=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.110", "", { "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-Lam3gY1xszjQS/cebdLbWbtR21FtQI4ke7QHqbBQ6AyVJF0CGUtS/ePL9zNq6wHNmJ95FUDGS6W+Qx/l6RPSzA=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2503,6 +2503,16 @@
"@radix-ui/rect": ["@radix-ui/rect@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ=="],
"@redis/bloom": ["@redis/bloom@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag=="],
"@redis/client": ["@redis/client@6.2.1", "", { "dependencies": { "cluster-key-slot": "1.1.2" }, "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q=="],
"@redis/json": ["@redis/json@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ=="],
"@redis/search": ["@redis/search@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ=="],
"@redis/time-series": ["@redis/time-series@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw=="],
"@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.8.1", "", {}, "sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig=="],
"@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="],
@@ -3601,7 +3611,7 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
@@ -3879,7 +3889,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.107", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ=="],
"effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -4541,8 +4551,6 @@
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"lang-map": ["lang-map@0.4.0", "", { "dependencies": { "language-map": "^1.1.0" } }, "sha512-oiSqZIEUnWdFeDNsp4HId4tAxdFbx5iMBOwA3666Fn2L8Khj8NiD9xRvMsGmKXopPVkaDFtSv3CJOmXFUB0Hcg=="],
"language-map": ["language-map@1.5.0", "", {}, "sha512-n7gFZpe+DwEAX9cXVTw43i3wiudWDDtSn28RmdnS/HCPr284dQI/SztsamWanRr75oSlKSaGbV2nmWCTzGCoVg=="],
@@ -5225,6 +5233,8 @@
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"redis": ["redis@6.2.1", "", { "dependencies": { "@redis/bloom": "6.2.1", "@redis/client": "6.2.1", "@redis/json": "6.2.1", "@redis/search": "6.2.1", "@redis/time-series": "6.2.1" } }, "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
@@ -6779,6 +6789,8 @@
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"ioredis/cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=",
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=",
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=",
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg="
"x86_64-linux": "sha256-SBRJwBuBi++5+x8ECufVqMKZMiw9OzF1gr6r/94KpPo=",
"aarch64-linux": "sha256-A/FpmriQw8HCwLffkYQ2FEycUE9dWW5PbSzCaiAvaLU=",
"aarch64-darwin": "sha256-9YXDArPsKlBPZoFuvjXCFoGd0pmOEDAG6qN6VhdSi64=",
"x86_64-darwin": "sha256-m9I3g+UZnwmKflXaZReu9y+4S853wDkq9/zZ2YTEO3I="
}
}
+5 -5
View File
@@ -37,10 +37,10 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.107",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -71,7 +71,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.107",
"effect": "4.0.0-rc.110",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -502,7 +502,16 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (message.role === "system") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
// Vertex currently returns 404 for a terminal system message after local tool results.
const vertexToolResultUpdate =
request.model.provider === "google-vertex" &&
request.messages[index - 1]?.role === "tool" &&
request.messages[index + 1] === undefined
if (
supportsNativeSystemUpdates(request) &&
!vertexToolResultUpdate &&
canUseNativeSystemUpdate(request.messages, index)
) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
+9 -2
View File
@@ -289,7 +289,9 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
if (previous?.role === "user")
// Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
@@ -377,7 +379,12 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
},
})
}
contents.push({ role: "user", parts })
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
else contents.push({ role: "user", parts })
}
return contents
@@ -159,6 +159,14 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safety_identifier: Schema.optional(Schema.String),
stream_options: Schema.optional(
Schema.Struct({
include_obfuscation: Schema.optional(Schema.Boolean),
}),
),
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
@@ -179,6 +187,8 @@ export const coreFields = {
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
@@ -578,6 +588,12 @@ const lowerOptions = (request: LLMRequest) => {
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.streamOptions?.includeObfuscation !== undefined
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
@@ -627,6 +643,8 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -47,9 +47,17 @@ export const AllowedTools = Schema.Struct({
})
export type AllowedTools = typeof AllowedTools.Type
export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
@@ -18,6 +18,14 @@ const opus48 = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-opus-4-8" })
const vertexOpus48 = AnthropicMessages.route
.with({
provider: "google-vertex",
endpoint: { baseURL: "https://vertex.test/v1/" },
auth: Auth.header("authorization", "Bearer test"),
})
.model({ id: "claude-opus-4-8" })
const request = LLM.request({
id: "req_1",
model,
@@ -277,6 +285,105 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
])
}),
)
it.effect("keeps a terminal direct Anthropic system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
],
},
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("keeps an ordinary terminal Vertex system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [Message.user("Before."), Message.system("Operator update.")],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
+89
View File
@@ -139,6 +139,95 @@ describe("Gemini route", () => {
}),
)
it.effect("keeps system updates separate from function responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
Message.system("Update."),
Message.system("Later update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
{
role: "user",
parts: [
{ text: "<system-update>\nUpdate.\n</system-update>" },
{ text: "<system-update>\nLater update.\n</system-update>" },
],
},
])
}),
)
it.effect("merges parallel tool results into one function-response turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "noon" },
},
},
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -126,6 +126,10 @@ describe("Open Responses-compatible route", () => {
providerOptions: {
reasoningEffort: "low",
store: true,
metadata: { environment: "test" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
@@ -136,6 +140,7 @@ describe("Open Responses-compatible route", () => {
LLM.request({
model,
prompt: "Think.",
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
@@ -143,6 +148,12 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
metadata: { environment: "test" },
safety_identifier: "user_123",
stream_options: { include_obfuscation: false },
top_logprobs: 3,
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
@@ -1284,6 +1284,7 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
@@ -1293,6 +1294,10 @@ describe("OpenAI Responses route", () => {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
metadata: { environment: "test", tenant: "acme" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 5,
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
@@ -1306,6 +1311,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
expect(prepared.body.top_logprobs).toBe(5)
expect(prepared.body.presence_penalty).toBe(0.25)
expect(prepared.body.frequency_penalty).toBe(-0.25)
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.107",
"effect": "4.0.0-rc.110",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
-1
View File
@@ -6,7 +6,6 @@ export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type MessageApi = Client["message"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
+2 -4
View File
@@ -111,7 +111,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
const durable = events[1]
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
expect(durable.created).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
@@ -219,9 +219,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000,
)
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
expect(logged.at(-1)).toEqual(synced)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
+1 -121
View File
@@ -6,7 +6,7 @@ import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { DateTime, Effect, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
import { AISDK } from "../aisdk.js"
import { Catalog } from "../catalog.js"
@@ -27,19 +27,8 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { WebSearch } from "../websearch.js"
import { PluginHooks } from "./hooks.js"
import { Session } from "../session.js"
import { SessionMessage } from "../session/message.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
type SessionListInput = Exclude<Parameters<Plugin.Context["session"]["list"]>[0], undefined>
type SessionListCursor = Exclude<SessionListInput["cursor"], undefined>
const MessageCursor = Schema.Struct({
id: SessionMessage.ID,
order: Schema.Literals(["asc", "desc"]),
direction: Schema.Literals(["previous", "next"]),
})
export const make = Effect.fn("PluginHost.make")(function* (
plugin: import("../plugin.js").Interface,
pluginID: string = "test",
@@ -78,57 +67,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
ref.directory === location.directory && ref.workspaceID === location.workspaceID
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
const sessionList = (input?: SessionListInput, parentID?: Session.ID) =>
Effect.gen(function* () {
const decoded = input?.cursor === undefined ? sessionListQuery(input) : yield* decodeSessionCursor(input.cursor)
const query = parentID === undefined ? decoded : { ...decoded, parentID }
const page = yield* runtime.session.list({ ...query, limit: input?.limit ?? 50 })
const first = page.data[0]
const last = page.data.at(-1)
return {
data: page.data,
cursor: {
previous:
first === undefined
? undefined
: encodeSessionCursor(query, {
id: first.id,
time: DateTime.toEpochMillis(first.time.updated),
direction: "previous",
}),
next:
last === undefined
? undefined
: encodeSessionCursor(query, {
id: last.id,
time: DateTime.toEpochMillis(last.time.updated),
direction: "next",
}),
},
}
})
const sessionMessages = (input: Parameters<Plugin.Context["session"]["messages"]>[0]) =>
Effect.gen(function* () {
if (input.cursor !== undefined && input.order !== undefined)
return yield* Effect.fail(new Error("Invalid cursor"))
const decoded = input.cursor === undefined ? undefined : yield* decodeMessageCursor(input.cursor)
const order = decoded?.order ?? input.order ?? "desc"
const messages = yield* runtime.session.messages({
sessionID: input.sessionID,
limit: input.limit ?? 50,
order,
cursor: decoded === undefined ? undefined : { id: decoded.id, direction: decoded.direction },
})
const first = messages[0]
const last = messages.at(-1)
return {
data: messages,
cursor: {
previous: first === undefined ? undefined : encodeMessageCursor(first, order, "previous"),
next: last === undefined ? undefined : encodeMessageCursor(last, order, "next"),
},
}
})
return {
app,
@@ -453,9 +391,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
list: sessionList,
children: (input) => sessionList({ cursor: input.cursor, limit: input.limit }, input.sessionID),
messages: sessionMessages,
create: (input) =>
runtime.session.create({
id: input?.id,
@@ -477,61 +412,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
} satisfies Plugin.Context
})
function sessionListQuery(input?: SessionListInput): Session.ListInput {
const common = {
workspaceID: input?.workspace,
search: input?.search,
order: input?.order,
parentID: input?.parentID,
}
if (input?.directory !== undefined) return { ...common, directory: input.directory }
if (input?.project !== undefined) return { ...common, project: input.project, subpath: input.subpath }
return common
}
function encodeSessionCursor(query: Session.ListInput, anchor: Session.ListAnchor): SessionListCursor {
const value = {
workspace: query.workspaceID,
search: query.search,
order: query.order,
parentID: query.parentID,
anchor,
...("directory" in query ? { directory: query.directory } : {}),
...("project" in query ? { project: query.project, subpath: query.subpath } : {}),
}
return Buffer.from(JSON.stringify(value)).toString("base64url") as SessionListCursor
}
function decodeSessionCursor(input: string) {
return Effect.try({
try: () => JSON.parse(Buffer.from(input, "base64url").toString("utf8")),
catch: () => new Error("Invalid cursor"),
}).pipe(
Effect.flatMap((value) => {
if (typeof value !== "object" || value === null) return Effect.fail(new Error("Invalid cursor"))
return Schema.decodeUnknownEffect(Session.ListInput)({
...value,
workspaceID: "workspace" in value ? value.workspace : undefined,
})
}),
Effect.mapError(() => new Error("Invalid cursor")),
)
}
function encodeMessageCursor(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") {
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
}
function decodeMessageCursor(input: string) {
return Effect.try({
try: () => JSON.parse(Buffer.from(input, "base64url").toString("utf8")),
catch: () => new Error("Invalid cursor"),
}).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(MessageCursor)),
Effect.mapError(() => new Error("Invalid cursor")),
)
}
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
-2
View File
@@ -13,7 +13,6 @@ import { Session } from "../session.js"
export interface Interface {
readonly session: Pick<
Session.Interface,
| "list"
| "get"
| "create"
| "messages"
@@ -70,7 +69,6 @@ export const layerWithCell = (cell: Cell) =>
Service,
Service.of({
session: {
list: (input) => require(cell, (runtime) => runtime.session.list(input)),
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
+10 -1
View File
@@ -21,7 +21,11 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
/**
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Compose with `awaitIdle` when settlement matters.
*/
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -141,6 +145,11 @@ export const layer = Layer.effect(
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
// promotes them, and a control item behind a queued prompt waits its turn.
// Interruption acknowledges before cleanup settles, so this wake usually lands
// on the stopping execution's doorbell and starts the successor at settle.
// Reading the inbox concurrently with the dying drain is safe: delivery consumes
// rows inside uninterruptible publications, so a steer row is either still
// promotable here or was fully delivered and needs no resumption.
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (next === undefined) return
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
+29 -9
View File
@@ -11,7 +11,11 @@ export interface Coordinator<Key, E, Reason = never> {
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
/**
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement.
*/
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
@@ -26,7 +30,13 @@ export interface Coordinator<Key, E, Reason = never> {
* with this execution's exit.
*/
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
/**
* Resolves with the execution's exit as a success value. Success-valued on purpose:
* completing a Deferred with an interrupted exit interrupts suspended waiters as it
* resumes them, and can starve later waiters of their resume entirely
* (Effect-TS/effect#7364). Joiners flatten the exit; idleness waiters just await.
*/
readonly done: Deferred.Deferred<Exit.Exit<void, E>>
owner?: Fiber.Fiber<void>
scope: Promotable
pendingWake?: Promotable
@@ -74,7 +84,7 @@ export const make = <Key, E, Reason = never>(options: {
const start = (key: Key, force: boolean, scope: Promotable) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
scope,
stopping: false,
}
@@ -104,7 +114,7 @@ export const make = <Key, E, Reason = never>(options: {
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
Deferred.doneUnsafe(execution.done, Exit.succeed(exit))
}
const run = (key: Key): Effect.Effect<void, E> =>
@@ -113,9 +123,9 @@ export const make = <Key, E, Reason = never>(options: {
if (execution !== undefined) {
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done)
return Deferred.await(execution.done).pipe(Effect.flatten)
}
return Deferred.await(start(key, true, "input").done)
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
})
const wake = (key: Key, scope: Promotable = "input") =>
@@ -132,13 +142,23 @@ export const make = <Key, E, Reason = never>(options: {
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void
if (execution === undefined || execution.stopping) return Effect.void
if (execution.owner === undefined) {
// Settlement window: the owner exited but the settled hook has not finished. The
// terminal outcome is already decided, so no reason attaches — but the interrupt
// still claims the recorded wakes so settle does not start a dead-intent successor.
execution.pendingWake = undefined
return Effect.void
}
execution.stopping = true
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
// Wakes arriving during cleanup are new admissions and restart normally at settle.
execution.pendingWake = undefined
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner)
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
fork(Fiber.interrupt(execution.owner))
return Effect.void
})
// One execution's `done` already spans coalesced continuations; re-check after it
@@ -147,7 +167,7 @@ export const make = <Key, E, Reason = never>(options: {
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
+1 -53
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -14,7 +14,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { Money } from "@opencode-ai/schema/money"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -25,57 +24,6 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("exposes paginated session history reads", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
const location = yield* Location.Service
const parentID = Session.ID.make("ses_parent")
const child = (id: string, updated: number) =>
Session.Info.make({
id: Session.ID.make(id),
parentID,
projectID: location.project.id,
cost: Money.USD.make(0),
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(updated), updated: DateTime.makeUnsafe(updated) },
location: Location.Ref.make({ directory: location.directory }),
})
const firstChild = child("ses_first", 1)
const secondChild = child("ses_second", 2)
const seen: unknown[] = []
const host = yield* PluginHost.make(plugins).pipe(
Effect.provideService(
PluginRuntime.Service,
PluginRuntime.Service.of({
...runtime,
session: {
...runtime.session,
list: (input) => {
seen.push(input)
return Effect.succeed({ data: [input?.anchor === undefined ? firstChild : secondChild] })
},
messages: (input) => {
seen.push(input)
return Effect.succeed([])
},
},
}),
),
)
const first = yield* host.session.children({ sessionID: parentID, limit: 1 })
const second = yield* host.session.children({ sessionID: parentID, limit: 1, cursor: first.cursor.next })
const messages = yield* host.session.messages({ sessionID: parentID })
expect(first.data).toHaveLength(1)
expect(second.data).toHaveLength(1)
expect(second.data[0]?.id).not.toBe(first.data[0]?.id)
expect(messages.data).toEqual([])
expect(seen).toHaveLength(3)
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
-3
View File
@@ -115,9 +115,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
list: overrides.session?.list ?? (() => Effect.die("unused session.list")),
children: overrides.session?.children ?? (() => Effect.die("unused session.children")),
messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
-35
View File
@@ -100,41 +100,6 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts session history reads through the protocol schema", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const host = testHost({
session: {
list: (input) => {
seen.push(input)
return Effect.succeed({ data: [], cursor: {} })
},
messages: (input) => {
seen.push(input)
return Effect.succeed({ data: [], cursor: {} })
},
},
})
yield* PluginPromise.fromPromise(
define({
id: "promise-session-history",
setup: async (ctx) => {
await ctx.session.list({ parentID: null, limit: 2 })
await ctx.session.children({ sessionID: Session.ID.make("ses_parent"), limit: 3 })
await ctx.session.messages({ sessionID: Session.ID.make("ses_parent"), limit: 4, order: "asc" })
},
}),
).effect(host)
expect(seen).toEqual([
{ parentID: null, limit: 2 },
{ parentID: "ses_parent", limit: 3 },
{ sessionID: "ses_parent", limit: 4, order: "asc" },
])
}),
)
it.effect("forwards transient session generation", () =>
Effect.gen(function* () {
const host = testHost({
@@ -622,6 +622,7 @@ describe("SessionRunCoordinator", () => {
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session")
yield* coordinator.awaitIdle("session")
expect(settled).toHaveLength(1)
expect(settled[0] !== undefined && Exit.isFailure(settled[0]) && Cause.hasInterrupts(settled[0].cause)).toBe(
@@ -632,6 +633,69 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("acknowledges interruption before cleanup settles", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const settled: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
settled: (_key, _exit, reason) => Effect.sync(() => void settled.push(reason)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
// Interrupt resolves while the cleanup gate is still closed: acceptance, not settlement.
yield* coordinator.interrupt("session", "user")
expect(settled).toHaveLength(0)
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
// Repeating the interrupt during cleanup stays an immediate no-op.
yield* coordinator.interrupt("session", "user")
yield* Deferred.await(cleanupStarted)
yield* Deferred.succeed(cleanupGate, undefined)
yield* coordinator.awaitIdle("session")
expect(settled).toEqual(["user"])
expect(yield* coordinator.active).toEqual(new Set())
}),
),
)
it.effect("an interrupt during terminal settlement claims the recorded wake", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let drains = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.sync(() => void drains++),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session")
yield* Deferred.await(settling)
// The owner has exited; this wake lands on the settling execution's doorbell.
yield* coordinator.wake("session")
// The interrupt claims it: settle must not start a successor for the dead intent.
yield* coordinator.interrupt("session")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(drains).toBe(1)
expect(yield* coordinator.active).toEqual(new Set())
}),
),
)
it.effect("trampolines synchronous self-waking execution", () =>
Effect.scoped(
Effect.gen(function* () {
+1 -1
View File
@@ -52,7 +52,7 @@
"typescript": "catalog:"
},
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.107"
"@effect/platform-node-shared": "4.0.0-rc.110"
},
"peerDependencies": {
"effect": "catalog:"
+2 -10
View File
@@ -1,4 +1,4 @@
import type { MessageApi, SessionApi } from "@opencode-ai/client/effect/api"
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
@@ -45,17 +45,9 @@ export interface SessionHooks {
readonly "http.response": SessionHttpResponse
}
type SessionListInput = Exclude<Parameters<SessionApi<unknown>["list"]>[0], undefined>
export type SessionChildrenInput = Pick<SessionListInput, "cursor" | "limit"> & {
readonly sessionID: Session.ID
}
export type SessionDomain = Pick<
SessionApi<unknown>,
"list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly children: (input: SessionChildrenInput) => ReturnType<SessionApi<unknown>["list"]>
readonly messages: MessageApi<unknown>["list"]
readonly hook: ModelHooks<SessionHooks>
}
-13
View File
@@ -76,7 +76,6 @@ export function fromPromise(plugin: Plugin) {
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const MessageEndpoints = ClientApi.groups["server.message"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
@@ -120,10 +119,6 @@ export function fromPromise(plugin: Plugin) {
callback(draft)
}),
)
const sessionList = adaptApiMethod<Context["session"]["list"]>(
SessionEndpoints["session.list"],
host.session.list,
)
const context2: Context = {
app: host.app,
@@ -312,14 +307,6 @@ export function fromPromise(plugin: Plugin) {
register(
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
list: sessionList,
children: (input) =>
sessionList({
parentID: input.sessionID,
cursor: input.cursor,
limit: input.limit,
}),
messages: adaptApiMethod(MessageEndpoints["session.messages"], host.session.messages),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
+2 -10
View File
@@ -1,4 +1,4 @@
import type { MessageApi, SessionApi } from "@opencode-ai/client/promise/api"
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
@@ -45,17 +45,9 @@ export interface SessionHooks {
readonly "http.response": SessionHttpResponse
}
type SessionListInput = Exclude<Parameters<SessionApi["list"]>[0], undefined>
export type SessionChildrenInput = Pick<SessionListInput, "cursor" | "limit"> & {
readonly sessionID: Session.ID
}
export type SessionDomain = Pick<
SessionApi,
"list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly children: (input: SessionChildrenInput) => ReturnType<SessionApi["list"]>
readonly messages: MessageApi["list"]
readonly hook: ModelHooks<SessionHooks>
}
+1 -1
View File
@@ -1376,7 +1376,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting />
<Reconnecting managed={client.restart !== undefined} />
</Show>
<MigrationOverlay />
<Toast />
+7 -3
View File
@@ -2,7 +2,7 @@ import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
export function Reconnecting(props: { managed?: boolean }) {
const theme = useTheme("elevated")
return (
@@ -28,8 +28,12 @@ export function Reconnecting() {
paddingRight={2}
gap={1}
>
<Spinner color={theme.text.default}>Restarting service...</Spinner>
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
<Spinner color={theme.text.default}>{props.managed ? "Restarting service..." : "Connection lost..."}</Spinner>
<text fg={theme.text.subdued}>
{props.managed
? "Your session will resume automatically."
: "Reconnecting to the server automatically."}
</text>
</box>
</box>
)
+4 -9
View File
@@ -115,7 +115,6 @@ type ResolvedSession = {
type RuntimeState = {
sdk: RunInput["sdk"]
shown: boolean
aborting: boolean
model: RunInput["model"]
defaultModel: RunInput["model"]
providers: RunProvider[]
@@ -213,7 +212,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const state: RuntimeState = {
sdk: ctx.sdk,
shown: !session.first,
aborting: false,
model: ctx.model ?? session.model,
defaultModel: undefined,
providers: [],
@@ -368,20 +366,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
},
onInterrupt: () => {
if (!state.sessionID || state.aborting) {
if (!state.sessionID) {
return false
}
state.aborting = true
// No in-flight guard: interruption acknowledges immediately server-side and repeating
// it is an idempotent no-op, so repeated presses are never swallowed.
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: state.sdk.session.interrupt({ sessionID: state.sessionID, continue: true })
)
.catch(() => {})
.finally(() => {
state.aborting = false
})
).catch(() => {})
return true
},
onBackground: () => {
+25 -5
View File
@@ -143,6 +143,8 @@ type State = {
closed: boolean
initial: boolean
rootActive: boolean
/** Bumped on every root execution lifecycle event; guards paints against stale acks. */
executionEpoch: number
buffered?: ReplayBuffer
errors: Set<string>
pending: Map<string, FooterQueuedPrompt>
@@ -474,6 +476,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
closed: false,
initial: true,
rootActive: false,
executionEpoch: 0,
errors: new Set(),
pending: new Map(),
admitted: new Set(),
@@ -512,6 +515,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})
controller.signal.addEventListener("abort", () => subagents.close(), { once: true })
// The one "go idle" transition, shared by settlement, terminal events, and the
// interrupt ack so the flag and the paint cannot drift apart.
const paintIdle = (status: string) => {
state.rootActive = false
write([], { phase: "idle", status })
}
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
if (!state.initial && state.buffered === undefined)
@@ -790,8 +800,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const settleSession = async (client: OpenCodeClient) => {
await client.session.wait({ sessionID: input.sessionID }, { signal: controller.signal })
for (const message of await projectedMessages(client, controller.signal)) renderMessage(message, true, true)
state.rootActive = false
write([], { phase: "idle", status: blockerStatus(state.view) })
paintIdle(blockerStatus(state.view))
await input.footer.idle()
}
@@ -1297,6 +1306,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.execution.started") {
state.executionEpoch++
state.rootActive = true
write([], { phase: "running" })
return
@@ -1306,8 +1316,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
) {
state.rootActive = false
write([], { phase: "idle", status: "" })
state.executionEpoch++
paintIdle("")
const current = state.wait
if (!current) return
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
@@ -1780,7 +1790,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (state.wait) state.wait.interrupted = true
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
// Paint idle at the ack, not at settlement: the server accepts interruption immediately
// while cleanup finishes asynchronously, and the terminal execution event re-confirms.
// A failed request paints nothing, so the two-press gesture stays available for retry,
// and a lifecycle event racing the ack wins via the epoch guard.
const epoch = state.executionEpoch
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).then(
() => {
if (state.executionEpoch === epoch) paintIdle(blockerStatus(state.view))
},
() => {},
)
},
selectSubagent(sessionID) {
subagents.select(sdk, sessionID)
+16 -16
View File
@@ -192,22 +192,22 @@ Its read and action methods use the same inputs and responses as the client. It
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
and plugin options.
| Capability | Available operations |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
| `ctx.catalog.provider` | `list`, `get` |
| `ctx.catalog.model` | `list`, `get`, `default` |
| `ctx.catalog` | `transform`, `reload` |
| `ctx.command` | `list`, `transform`, `reload` |
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `list`, `children`, `messages`, `create`, `get`, `prompt`, `generate`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
| Capability | Available operations |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
| `ctx.catalog.provider` | `list`, `get` |
| `ctx.catalog.model` | `list`, `get`, `default` |
| `ctx.catalog` | `transform`, `reload` |
| `ctx.command` | `list`, `transform`, `reload` |
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
### Transform hooks