Compare commits

...
Author SHA1 Message Date
kitlangton f5ec50ad9b test(core): skip platform-neutral shell retention test on Windows 2026-08-23 23:30:34 +00:00
opencode-agent[bot]andrekram1-node 7da10aa65d fix(ai): retry detail-free response errors (#44537)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-23 17:39:55 -05:00
Luke Parker d80b0a1e7e fix(desktop): reconnect to elected service (#44369) 2026-08-24 08:27:04 +10:00
opencode-agent[bot] 481125f617 chore: update nix node_modules hashes 2026-08-23 22:15:43 +00:00
Kit Langton 59d0d64a6b chore: upgrade Effect to rc.111 (#44518) 2026-08-23 18:01:44 -04:00
opencode-agent[bot]andthdxr e0cdf1ed09 fix(tui): restore global prompt history (#44416)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-23 17:53:16 -04:00
Kit Langton 9e50d76416 fix(codegen): write prettier-stable schema snapshot (#43985) 2026-08-23 17:34:47 -04:00
Dax 771c0f5850 fix(core): avoid stale MCP OAuth snapshots destroying rotated credentials (#44527) 2026-08-23 17:02:51 -04:00
Kit Langton b1a0ef91bb refactor(core): use FiberMap for title generation (#44519) 2026-08-23 16:22:43 -04:00
Kit Langton 2e67cee75b fix(tui): recover from terminal forms (#44516) 2026-08-23 16:21:28 -04:00
opencode-agent[bot]andrekram1-node 890735c1d7 feat(plugin): expose tool input schema (#44510)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-23 14:19:57 -05:00
Aiden Cline 049f0b0c3b fix(ai): handle additional usage locations and tool history for OpenAI Chat (#44502) 2026-08-23 13:48:30 -05:00
Aiden Cline 6020f36862 fix(ai): handle anthropic error events and ping frames (#44500) 2026-08-23 13:45:21 -05:00
Simon Klee 0d24ebdbbe tui: preserve selection after copy (#44496)
Copy-on-select reset the click counter, so a third click could not select the full line. Keep the selection after copying to preserve multi-click input.
2026-08-23 20:40:19 +02:00
opencode-agent[bot]andrekram1-node b22c182406 fix(ai): preserve inline file data URLs (#44499)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-23 13:39:38 -05:00
Aiden Cline c7f2f367e3 refactor(ai): remove deprecated local shell (#44497) 2026-08-23 13:36:10 -05:00
Aiden Cline ccd2135e7a fix(ai): tolerate explicit nulls in Gemini stream payloads (#44490) 2026-08-23 13:17:13 -05:00
Brendan Allan c7c22b9d7e fix(app): stabilize pane transitions (#44484) 2026-08-23 17:59:00 +00:00
Aiden Cline 67e99993f5 fix(ai): align Responses compatibility (#44482) 2026-08-23 12:53:14 -05:00
76 changed files with 1867 additions and 1295 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
+15 -13
View File
@@ -54,12 +54,12 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -191,7 +191,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.110",
"effect": "4.0.0-rc.111",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -525,7 +525,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.111",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -1078,10 +1078,10 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@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",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1118,7 +1118,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"effect": "4.0.0-rc.111",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1685,13 +1685,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@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/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "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.111" }, "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-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
"@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": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
"@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/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
"@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=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
"@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=="],
@@ -3853,7 +3853,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"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=="],
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -6173,6 +6173,8 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-P8AZ2THuXiET4hiSYTwGGdTgbm2l3XGZGJxQwg6lusE=",
"aarch64-linux": "sha256-73LxDeDPQspY4Bn/gbGU43j/k7gBZ5ywMMpUjDl3elg=",
"aarch64-darwin": "sha256-sc9YyTAFlbWxlFb9vwFYLs6IYd3SRoju9mQufzeQdd8=",
"x86_64-darwin": "sha256-nnk637DELjzGB3t9aLDYbHz5h3Dd3nvWcWGdaQk8yPM="
"x86_64-linux": "sha256-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
}
}
+5 -5
View File
@@ -39,10 +39,10 @@
"packages/stats/*"
],
"catalog": {
"@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",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -73,7 +73,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
+1 -1
View File
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
- Input failed the `parameters` Schema.
- The handler returned a `ToolFailure`.
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
@@ -41,6 +41,7 @@ const SSE_EVENTS = new Set([
"content_block_start",
"content_block_delta",
"content_block_stop",
"ping",
"error",
])
export const framing = Framing.sseEvents(SSE_EVENTS)
@@ -1004,11 +1005,13 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
}
const onError = (event: AnthropicEvent) =>
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
})
Effect.fail(
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
}),
)
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
+48 -35
View File
@@ -17,7 +17,7 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -82,10 +82,15 @@ export type ProviderOptionsInput = OptionsInput
// =============================================================================
// Request Body Schema
// =============================================================================
// Gemini is known to send explicit `null` for optional streaming fields
// (usage counts, flags, whole subtrees), so every response-side optional uses
// `optionalNull` instead of bare `Schema.optional`. The same part/content
// schemas lower the outbound request body; encoding drops `undefined` keys,
// so the shared schemas stay safe there.
const GeminiTextPart = Schema.Struct({
text: Schema.String,
thought: Schema.optional(Schema.Boolean),
thoughtSignature: Schema.optional(Schema.String),
thought: optionalNull(Schema.Boolean),
thoughtSignature: optionalNull(Schema.String),
})
const GeminiInlineDataPart = Schema.Struct({
@@ -98,11 +103,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
id: optionalNull(Schema.String),
name: Schema.String,
args: Schema.optional(Schema.Unknown),
}),
thoughtSignature: Schema.optional(Schema.String),
thoughtSignature: optionalNull(Schema.String),
})
const GeminiFunctionResponsePart = Schema.Struct({
@@ -122,8 +127,8 @@ const GeminiContentPart = Schema.Union([
])
const GeminiContent = Schema.Struct({
role: Schema.Literals(["user", "model"]),
parts: Schema.Array(GeminiContentPart),
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(GeminiContentPart)),
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
@@ -186,33 +191,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
const GeminiUsage = Schema.Struct({
cachedContentTokenCount: Schema.optional(Schema.Number),
thoughtsTokenCount: Schema.optional(Schema.Number),
promptTokenCount: Schema.optional(Schema.Number),
candidatesTokenCount: Schema.optional(Schema.Number),
totalTokenCount: Schema.optional(Schema.Number),
cachedContentTokenCount: optionalNull(Schema.Number),
thoughtsTokenCount: optionalNull(Schema.Number),
promptTokenCount: optionalNull(Schema.Number),
candidatesTokenCount: optionalNull(Schema.Number),
totalTokenCount: optionalNull(Schema.Number),
})
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: Schema.optional(GeminiContent),
finishReason: Schema.optional(Schema.String),
content: optionalNull(GeminiContent),
finishReason: optionalNull(Schema.String),
})
const GeminiPromptFeedback = Schema.StructWithRest(
Schema.Struct({
blockReason: Schema.optional(Schema.String),
blockReasonMessage: Schema.optional(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown),
blockReason: optionalNull(Schema.String),
blockReasonMessage: optionalNull(Schema.String),
safetyRatings: optionalNull(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({
candidates: optionalArray(GeminiCandidate),
promptFeedback: Schema.optional(GeminiPromptFeedback),
usageMetadata: Schema.optional(GeminiUsage),
candidates: optionalNull(Schema.Array(GeminiCandidate)),
promptFeedback: optionalNull(GeminiPromptFeedback),
usageMetadata: optionalNull(GeminiUsage),
})
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
@@ -305,8 +310,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const previous = contents.at(-1)
// 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 }] }
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
}
@@ -397,8 +402,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
// 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] }
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 })
}
@@ -488,21 +493,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined
const cached = usage.cachedContentTokenCount
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
const promptTokens = usage.promptTokenCount ?? undefined
const cached = usage.cachedContentTokenCount ?? undefined
const thoughts = usage.thoughtsTokenCount ?? undefined
const visible = usage.candidatesTokenCount ?? undefined
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
// inclusive `outputTokens` the contract expects. Only compute the total
// when the visible component is reported — otherwise we'd fabricate an
// inclusive number from a partial breakdown.
const outputTokens =
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
return new Usage({
inputTokens: usage.promptTokenCount,
inputTokens: promptTokens,
outputTokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: usage.thoughtsTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: { google: usage },
})
}
@@ -537,7 +546,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
}
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
// the "nothing to finish" check below keeps its meaning.
const promptBlockReason =
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return []
@@ -586,7 +598,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const part of candidate.content.parts) {
for (const part of candidate.content.parts ?? []) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -625,7 +637,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
const supplied = part.functionCall.id
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
const supplied = part.functionCall.id ?? undefined
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
@@ -642,7 +655,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
name: part.functionCall.name,
input,
providerMetadata:
part.thoughtSignature === undefined ? undefined : googleMetadata({ thoughtSignature: part.thoughtSignature }),
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
}),
)
hasToolCalls = true
+14 -5
View File
@@ -5,6 +5,7 @@ import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
ProviderInternalReason,
Usage,
type FinishReason,
type JsonSchema,
@@ -428,7 +429,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
...(url ? { file_url: url } : { file_data: media.base64 }),
...(url ? { file_url: url } : { file_data: media.dataUrl }),
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
@@ -580,8 +581,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
if (store !== false && id && !hostedToolReferences.has(id))
input.push({ type: "item_reference", id })
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
@@ -1105,11 +1105,19 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
: typeof event.status_code === "number"
? event.status_code
: undefined
const reason =
event.type === "error" &&
event.error === undefined &&
event.response === undefined &&
summary === undefined &&
status === undefined
? new ProviderInternalReason({ message })
: classifyProviderFailure({ message, code, status, rawBody: body })
return new AIError({
module: id,
method: "stream",
body,
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
reason,
})
}
@@ -1126,7 +1134,8 @@ export const step = (state: ParserState, event: Event) => {
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
if (!event.item_id || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
+40 -8
View File
@@ -156,6 +156,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
cached_tokens: optionalNull(Schema.Number),
prompt_cache_hit_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
@@ -204,11 +207,16 @@ const OpenAIChatDelta = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatChoice = Schema.StructWithRest(
Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
usage: optionalNull(OpenAIChatUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
@@ -509,6 +517,17 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
return messages
})
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
// whenever the conversation history contains tool calls/results. Send an
// explicit empty array when we have history but no active tools.
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
for (const message of messages) {
if (message.role === "tool") return true
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
}
return false
}
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
return {
@@ -532,12 +551,15 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
tools:
request.tools.length === 0
? undefined
? hasHistory
? []
: undefined
: request.tools.map((tool) =>
lowerTool(
tool,
@@ -581,11 +603,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides.
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
const cached =
(usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
@@ -691,8 +720,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
}),
})
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason !== undefined && rawFinishReason !== null
@@ -134,13 +134,12 @@ const HOSTED_TOOLS = {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
export const ServiceTier = Schema.declare<ServiceTier>(
(value): value is ServiceTier => typeof value === "string",
{ title: "ServiceTier" },
)
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const ServiceTierSchema = ServiceTier
export const TruncationSchema = Schema.Literals(Truncations)
export const AllowedTools = Schema.Struct({
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
+1 -1
View File
@@ -42,7 +42,7 @@ const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LL
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
...content,
parts: content.parts.map((part) => {
parts: (content.parts ?? []).map((part) => {
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
return part
+6 -2
View File
@@ -1,9 +1,13 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { serviceTier: "premium" },
})
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
+67
View File
@@ -1492,6 +1492,73 @@ describe("Gemini route", () => {
}),
)
it.effect("survives explicit null usage counts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
),
),
),
)
expect(response.text).toBe("Hi")
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
expect(response.usage?.inputTokens).toBeUndefined()
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
expect(response.usage?.reasoningTokens).toBeUndefined()
}),
)
it.effect("survives null candidates, content, parts, and finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: null },
{ candidates: [{ content: { role: "model", parts: null } }] },
{ candidates: [{ content: null, finishReason: null }] },
{
candidates: [
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
],
},
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("treats a null thought flag on a text part as visible output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
],
}),
),
),
)
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
expect(reasoningStart).toBeUndefined()
expect(response.reasoning ?? "").toBe("")
expect(response.text).toBe("Visible")
}),
)
it.effect("fails invalid stream events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -342,6 +342,7 @@ describe("OpenAI Chat route", () => {
},
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
],
tools: [],
stream: true,
stream_options: { include_usage: true },
})
@@ -78,6 +78,45 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
},
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
{
type: "function_call_output",
call_id: "call_1",
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
},
])
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
@@ -114,7 +153,12 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
input: [
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
],
})
}),
@@ -189,6 +233,7 @@ describe("Open Responses-compatible route", () => {
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
serviceTier: "provider-tier",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
@@ -213,6 +258,7 @@ describe("Open Responses-compatible route", () => {
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
service_tier: "provider-tier",
tool_choice: {
type: "allowed_tools",
mode: "auto",
@@ -188,13 +188,11 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits unsupported semantic service tiers", () =>
it.effect("passes through provider-defined service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
)
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
expect(prepared.body).not.toHaveProperty("service_tier")
expect(prepared.body.service_tier).toBe("scale")
}),
)
@@ -1273,7 +1271,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
@@ -1300,7 +1298,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "report.pdf", file_data: base64 },
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
])
}),
)
@@ -1333,7 +1331,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
@@ -1358,7 +1356,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
])
}),
)
@@ -2652,6 +2650,47 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
type: "computer_call",
id: "computer_1",
call_id: "call_1",
status: "completed",
action: { type: "click", x: 100, y: 200 },
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
{
type: "tool-call",
id: "computer_1",
name: "computer_use",
input: { type: "click", x: 100, y: 200 },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
{
type: "tool-result",
id: "computer_1",
name: "computer_use",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
])
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
@@ -2765,7 +2804,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
@@ -2796,7 +2835,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
@@ -2821,7 +2860,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "file",
file_data: "AAECAw==",
file_data: "data:application/x-tar;base64,AAECAw==",
},
],
},
@@ -3029,14 +3068,14 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to the raw payload when both error and response are absent", () =>
it.effect("classifies a detail-free error event as a transient provider failure", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
}),
@@ -131,8 +131,11 @@ test("animates review and terminal panels while caching hidden terminal content"
await resetTerminalBottomMotion(page)
await resetTerminalAnchorGaps(page)
await resetPanelGaps(page)
const reviewContent = page.locator('[data-component="session-review-v2"]')
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await reviewToggle.click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(page.locator("#review-panel")).toBeHidden()
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
await expect(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
@@ -142,6 +145,7 @@ test("animates review and terminal panels while caching hidden terminal content"
await expectPanelGapHeld(page)
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
await expectHeightMotions(page, "session-side-region", 3)
await expectHeightMotions(page, "session-side-terminal-region", 3)
@@ -161,14 +165,18 @@ test("animates review and terminal panels while caching hidden terminal content"
await reviewToggle.click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await expectWidthMotions(page, 2)
await expectSideSlideSettled(page, 2)
await expectHiddenSideAligned(page)
await resetHeightMotions(page)
await resetHorizontalScrolls(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await expectWidthMotions(page, 3)
await expectSideMotionSettled(page)
await expectSideSlideSettled(page, 3)
await expectNoHeightMotion(page)
await expectNoHorizontalScroll(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeHidden()
@@ -209,6 +217,8 @@ test("animates review and terminal panels while caching hidden terminal content"
type MotionProbe = {
widths: number
widthEnds: number
horizontalScrolls: number[]
reviewWidths: number[]
paintGaps: { review: number; terminalSurface: number }[]
terminalContentSizes: { width: number; height: number }[]
@@ -225,6 +235,8 @@ async function installMotionProbe(page: Page) {
await page.evaluate(() => {
const probe: MotionProbe = {
widths: 0,
widthEnds: 0,
horizontalScrolls: [],
reviewWidths: [],
paintGaps: [],
terminalContentSizes: [],
@@ -299,10 +311,16 @@ async function installMotionProbe(page: Page) {
probe.heights.push(slot)
}
})
document.addEventListener("transitionend", (event) => {
if (!(event.target instanceof Element)) return
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
probe.widthEnds++
})
document.addEventListener("animationstart", (event) => {
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
probe.animations.push(event.animationName)
})
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
})
}
@@ -320,11 +338,10 @@ async function resetHeightMotions(page: Page) {
})
}
async function expectSideMotionSettled(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
async function expectSideSlideSettled(page: Page, count: number) {
await expect
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
.toBe(true)
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
.toBeGreaterThanOrEqual(count)
}
async function expectNoHeightMotion(page: Page) {
@@ -334,6 +351,40 @@ async function expectNoHeightMotion(page: Page) {
expect(heights).toEqual([])
}
async function expectHiddenSideAligned(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
const row = chat.parentElement.getBoundingClientRect()
const hidden = side.getBoundingClientRect()
return Math.max(
Math.abs(row.top - hidden.top),
Math.abs(row.right - hidden.right),
Math.abs(row.bottom - hidden.bottom),
)
}),
)
.toBeLessThanOrEqual(1)
}
async function resetHorizontalScrolls(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.horizontalScrolls = []
})
}
async function expectNoHorizontalScroll(page: Page) {
const scrolls = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
)
expect(Math.max(0, ...scrolls)).toBe(0)
expect(await page.evaluate(() => window.scrollX)).toBe(0)
}
async function expectReviewWidthStable(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
await expect
+37 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./client"
import { createOpenCodeEventSource, createServerTransport } from "./client"
const permission = {
id: "evt_permission",
@@ -84,3 +84,39 @@ describe("server event stream", () => {
second.dispose()
})
})
test("rotates HTTP and PTY clients together", async () => {
const requests: Array<{ url: string; authorization: string | null }> = []
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", username: "opencode", password: "first" },
fetch,
})
const initialPty = transport.pty
await transport.api.health.get()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
username: "opencode",
password: "second",
})
await transport.api.health.get()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/health",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/health",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
})
+42 -7
View File
@@ -72,11 +72,12 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const pty = createPtyClient(api, { url: server.http.url })
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
const events = createOpenCodeEventSource()
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
const connection = createClientConnection(api, {
const connection = createClientConnection(transport.api, {
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
@@ -85,7 +86,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
log: {
info(message, data) {
if (message !== "event stream disconnected") return
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed: !!reconnect, ...data })
},
},
})
@@ -93,14 +94,48 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return {
server,
scope,
url: server.http.url,
api,
pty,
get url() {
return transport.url
},
get api() {
return transport.api
},
get pty() {
return transport.pty
},
connection,
event: events.event,
}
}
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
update(http: ServerConnection.HttpBase): ServerApi
readonly url: string
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
return {
update(http: ServerConnection.HttpBase) {
state.current = build(http)
return state.current.api
},
get url() {
return state.current.http.url
},
get api() {
return state.current.api
},
get pty() {
return state.current.pty
},
}
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
+1 -1
View File
@@ -210,7 +210,7 @@ export namespace ServerConnection {
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
{ variant: "base"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
// WSL server (windows only)
| {
variant: "wsl"
+3 -3
View File
@@ -36,10 +36,10 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
queryFn: async () => [],
})
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
function makeQueryOptionsApi(scope: ServerScope, serverAPI: () => ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
path: () => loadPathQuery(scope, null, serverAPI.location),
path: () => loadPathQuery(scope, null, serverAPI().location),
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
}
}
@@ -52,7 +52,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!owner) throw new Error("ServerSync must be created within owner")
const booting = new Map<string, Promise<void>>()
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const [configQuery, pathQuery] = useQueries(() => ({
+2 -2
View File
@@ -101,11 +101,11 @@ export const focusTerminalById = (id: string) => {
const textarea = terminal.querySelector("textarea")
if (textarea instanceof HTMLTextAreaElement) {
textarea.focus()
textarea.focus({ preventScroll: true })
return true
}
terminal.focus()
terminal.focus({ preventScroll: true })
terminal.dispatchEvent(
typeof PointerEvent === "function"
? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
+2 -1
View File
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
@@ -241,6 +241,7 @@ export function SessionScreen(props: { session: SessionModel }) {
if (event.currentTarget !== event.target) return
if (event.animationName !== "side-region-presence-out") return
if (screen.side.region.open()) return
if (sideTerminalVisible()) return
setStore("sideRegionPresent", false)
setStore("sideReviewPresent", false)
}}
@@ -347,9 +347,9 @@ export const Terminal = (props: TerminalProps) => {
const focusTerminal = () => {
const t = term
if (!t) return
t.focus()
t.textarea?.focus()
setTimeout(() => t.textarea?.focus(), 0)
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
focus()
setTimeout(focus, 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-rc.110",
"effect": "4.0.0-rc.111",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
File diff suppressed because it is too large Load Diff
+51 -7
View File
@@ -6,7 +6,9 @@
import type {
AgentInfo,
CommandInfo,
FormCancelInput,
FormInfo,
FormReplyInput,
IntegrationInfo,
LocationRef,
LocationGetOutput,
@@ -37,7 +39,12 @@ import type {
import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import {
isFormAlreadySettledError,
isFormNotFoundError,
isPermissionNotFoundError,
type SessionPromptInput,
} from "../promise"
import { createStore, produce, reconcile } from "solid-js/store"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
@@ -120,6 +127,16 @@ function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
if (sessionID !== "global" || !ref) return undefined
return {
headers: {
"x-opencode-directory": encodeURIComponent(ref.directory),
...(ref.workspaceID ? { "x-opencode-workspace": ref.workspaceID } : {}),
},
}
}
function createSync() {
type Pending = { promise: Promise<void>; invalidated: boolean }
const state = new Map<string, true | Pending>()
@@ -228,6 +245,32 @@ export function createData(config: CreateDataInput) {
)
}
function removeForm(sessionID: string, formID: string, ref?: LocationRef) {
const forms = store.session.form[sessionID]
if (!forms) return false
const location = ref && locationKey(ref)
const next = forms.filter((form) => {
if (form.id !== formID) return true
if (sessionID !== "global" || !location) return false
return !form.location || locationKey(form.location) !== location
})
if (next.length === forms.length) return false
setStore("session", "form", sessionID, next)
return true
}
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
return request
.catch((error: unknown) => {
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
})
.then(() => {
if (!removeForm(input.sessionID, input.formID, ref)) return
result.session.form.invalidate(input.sessionID, ref)
void result.session.form.sync(input.sessionID, ref).catch(() => undefined)
})
}
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
const item = store.session.pending[sessionID]?.[index]
@@ -998,12 +1041,7 @@ export function createData(config: CreateDataInput) {
return
case "form.replied":
case "form.cancelled":
setStore(
"session",
"form",
event.data.sessionID,
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
)
removeForm(event.data.sessionID, event.data.id, event.location)
return
}
@@ -1420,6 +1458,12 @@ export function createData(config: CreateDataInput) {
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
)
},
reply(input: FormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: FormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
},
project: {
+14 -1
View File
@@ -48,7 +48,7 @@ async function generate() {
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
)
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
await Bun.write(snapshot, await formatJson(await Bun.file(path.join(incremental, name, "snapshot.json")).text()))
}
await fs.mkdir(full)
@@ -192,6 +192,19 @@ async function formatTypescript(input: string) {
})
}
// Drizzle emits every array multi-line; format the snapshot so regeneration
// diffs stay minimal against the prettier-styled checked-in copy.
async function formatJson(input: string) {
const prettier = await import("prettier")
const babel = await import("prettier/plugins/babel")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "json",
plugins: [babel.default, estree.default],
printWidth: 120,
})
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration.js"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}.js"`).join("\n")}
@@ -7,7 +7,6 @@ import {
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types.js"
import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell.js"
/**
* Check if a string is a file ID based on the given prefixes
@@ -23,13 +22,11 @@ export async function convertToOpenAIResponsesInput({
systemMessageMode,
fileIdPrefixes,
store,
hasLocalShellTool = false,
}: {
prompt: LanguageModelV3Prompt
systemMessageMode: "system" | "developer" | "remove"
fileIdPrefixes?: readonly string[]
store: boolean
hasLocalShellTool?: boolean
}): Promise<{
input: OpenAIResponsesInput
warnings: Array<SharedV3Warning>
@@ -138,25 +135,6 @@ export async function convertToOpenAIResponsesInput({
break
}
if (hasLocalShellTool && part.toolName === "local_shell") {
const parsedInput = localShellInputSchema.parse(part.input)
input.push({
type: "local_shell_call",
call_id: part.toolCallId,
id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
action: {
type: "exec",
command: parsedInput.action.command,
timeout_ms: parsedInput.action.timeoutMs,
user: parsedInput.action.user,
working_directory: parsedInput.action.workingDirectory,
env: parsedInput.action.env,
},
})
break
}
input.push({
type: "function_call",
call_id: part.toolCallId,
@@ -261,15 +239,6 @@ export async function convertToOpenAIResponsesInput({
}
}
if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") {
input.push({
type: "local_shell_call_output",
call_id: part.toolCallId,
output: localShellOutputSchema.parse(output.value).output,
})
break
}
let contentValue: string
switch (output.type) {
case "text":
@@ -9,8 +9,6 @@ export type OpenAIResponsesInputItem =
| OpenAIResponsesFunctionCall
| OpenAIResponsesFunctionCallOutput
| OpenAIResponsesComputerCall
| OpenAIResponsesLocalShellCall
| OpenAIResponsesLocalShellCallOutput
| OpenAIResponsesReasoning
| OpenAIResponsesItemReference
| OpenAIResponsesMcpApprovalResponse
@@ -69,26 +67,6 @@ export type OpenAIResponsesComputerCall = {
status?: string
}
export type OpenAIResponsesLocalShellCall = {
type: "local_shell_call"
id?: string
call_id: string
action: {
type: "exec"
command: string[]
timeout_ms?: number
user?: string
working_directory?: string
env?: Record<string, string>
}
}
export type OpenAIResponsesLocalShellCallOutput = {
type: "local_shell_call_output"
call_id: string
output: string
}
export type OpenAIResponsesItemReference = {
type: "item_reference"
id: string
@@ -199,9 +177,6 @@ export type OpenAIResponsesTool =
quality: "auto" | "low" | "medium" | "high" | undefined
size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined
}
| {
type: "local_shell"
}
export type OpenAIResponsesReasoning = {
type: "reasoning"
@@ -29,7 +29,6 @@ import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-rea
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
import { localShellInputSchema } from "./tool/local-shell.js"
const webSearchCallItem = z.object({
type: z.literal("web_search_call"),
@@ -86,20 +85,6 @@ const codeInterpreterCallItem = z.object({
.nullable(),
})
const localShellCallItem = z.object({
type: z.literal("local_shell_call"),
id: z.string(),
call_id: z.string(),
action: z.object({
type: z.literal("exec"),
command: z.array(z.string()),
timeout_ms: z.number().optional(),
user: z.string().optional(),
working_directory: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
}),
})
const imageGenerationCallItem = z.object({
type: z.literal("image_generation_call"),
id: z.string(),
@@ -205,7 +190,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
systemMessageMode: modelConfig.systemMessageMode,
fileIdPrefixes: this.config.fileIdPrefixes,
store,
hasLocalShellTool: hasOpenAITool("openai.local_shell"),
})
warnings.push(...inputWarnings)
@@ -462,7 +446,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
fileSearchCallItem,
codeInterpreterCallItem,
imageGenerationCallItem,
localShellCallItem,
z.object({
type: z.literal("function_call"),
call_id: z.string(),
@@ -560,22 +543,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
break
}
case "local_shell_call": {
content.push({
type: "tool-call",
toolCallId: part.call_id,
toolName: "local_shell",
input: JSON.stringify({ action: part.action } satisfies z.infer<typeof localShellInputSchema>),
providerMetadata: {
copilot: {
itemId: part.id,
},
},
})
break
}
case "message": {
for (const contentPart of part.content) {
if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) {
@@ -1093,27 +1060,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
result: value.item.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
})
} else if (value.item.type === "local_shell_call") {
ongoingToolCalls[value.output_index] = undefined
controller.enqueue({
type: "tool-call",
toolCallId: value.item.call_id,
toolName: "local_shell",
input: JSON.stringify({
action: {
type: "exec",
command: value.item.action.command,
timeoutMs: value.item.action.timeout_ms,
user: value.item.action.user,
workingDirectory: value.item.action.working_directory,
env: value.item.action.env,
},
} satisfies z.infer<typeof localShellInputSchema>),
providerMetadata: {
copilot: { itemId: value.item.id },
},
})
} else if (value.item.type === "message") {
if (currentTextId) {
controller.enqueue({
@@ -1528,7 +1474,6 @@ const responseOutputItemDoneSchema = z.object({
imageGenerationCallItem,
webSearchCallItem,
fileSearchCallItem,
localShellCallItem,
z.object({
type: z.literal("computer_call"),
id: z.string(),
@@ -70,12 +70,6 @@ export function prepareResponsesTools({
break
}
case "openai.local_shell": {
openaiTools.push({
type: "local_shell",
})
break
}
case "openai.web_search_preview": {
const args = webSearchPreviewArgsSchema.parse(tool.args)
openaiTools.push({
@@ -1,16 +0,0 @@
import { z } from "zod/v4"
export const localShellInputSchema = z.object({
action: z.object({
type: z.literal("exec"),
command: z.array(z.string()),
timeoutMs: z.number().optional(),
user: z.string().optional(),
workingDirectory: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
}),
})
export const localShellOutputSchema = z.object({
output: z.string(),
})
+34 -9
View File
@@ -273,28 +273,53 @@ export const layer = (options?: Options) =>
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
let current: Credential.OAuth | undefined = found.value
const integrationID = entry.integrationID
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await Effect.runPromise(credentials.list(integrationID))
const match = stored.find((credential) => credential.id === credentialID)
return match && match.value.type === "oauth" ? match.value : undefined
}
return MCPOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
current = undefined
const oauth = await readOAuthCredential()
if (!oauth || oauth.refresh !== presented) return
await Effect.runPromise(credentials.remove(credentialID))
},
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
// token fails with invalid_grant.
store: {
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
tokens: async () => {
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return MCPOAuth.toTokens(oauth)
},
saveTokens: async (tokens) => {
current = MCPOAuth.toCredential({
const previous = await readOAuthCredential()
const value = MCPOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
})
await Effect.runPromise(credentials.update(credentialID, { value: current }))
presented = value.refresh
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
},
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
+8 -13
View File
@@ -30,13 +30,7 @@ export interface Coordinator<Key, E, Reason = never> {
* with this execution's exit.
*/
type Execution<E, Reason> = {
/**
* 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>>
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
scope: Promotable
pendingWake?: Promotable
@@ -84,7 +78,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<Exit.Exit<void, E>>(),
done: Deferred.makeUnsafe<void, E>(),
scope,
stopping: false,
}
@@ -114,7 +108,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.succeed(exit))
Deferred.doneUnsafe(execution.done, exit)
}
const run = (key: Key): Effect.Effect<void, E> =>
@@ -122,10 +116,11 @@ export const make = <Key, E, Reason = never>(options: {
const execution = executions.get(key)
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).pipe(Effect.flatten)
if (execution.stopping)
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key, scope: Promotable = "input") =>
@@ -167,7 +162,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.andThen(awaitIdle(key)))
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
+6 -21
View File
@@ -10,7 +10,7 @@ import {
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
@@ -165,9 +165,7 @@ const layer = Layer.effect(
)
})
// Title generation starts once input is visible and must not delay model execution.
// The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
* Execution lifecycle is published per busy period by SessionExecution, not here.
@@ -334,7 +332,10 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0) yield* startTitle(sessionID)
if (promoted > 0)
yield* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
@@ -715,22 +716,6 @@ const layer = Layer.effect(
}
})
/** Starts one title request at a time after a successful step makes user input visible. */
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titlesRunning.has(sessionID)) return
titlesRunning.add(sessionID)
forkTitle(
title.generateForFirstPrompt(sessionID).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
titlesRunning.delete(sessionID)
}),
),
),
)
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
+1
View File
@@ -90,6 +90,7 @@ const layer = Layer.effect(
) {
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
tool: name,
inputSchema: definition(tool).inputSchema,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
+11 -3
View File
@@ -455,7 +455,7 @@ describe("Plugin", () => {
const registry = yield* Tool.Service
const executed: unknown[] = []
const seen: {
before?: unknown
before?: { input: unknown; inputSchema: unknown }
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
} = {}
@@ -480,7 +480,7 @@ describe("Plugin", () => {
yield* ctx.tool
.hook("execute.before", (event) =>
Effect.sync(() => {
seen.before = event.input
seen.before = { input: event.input, inputSchema: event.inputSchema }
event.input = { text: "before-mutated" }
}),
)
@@ -526,7 +526,15 @@ describe("Plugin", () => {
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
})
expect(seen.before).toEqual({ text: "original" })
expect(seen.before).toEqual({
input: { text: "original" },
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
additionalProperties: false,
},
})
expect(executed).toEqual([{ text: "before-mutated" }])
expect(seen.after).toEqual({
input: { text: "before-mutated" },
@@ -309,14 +309,20 @@ describe("SessionRunCoordinator", () => {
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
})
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* coordinator.interrupt("session", "user")
yield* Deferred.await(interrupted)
const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
const exits = yield* Fiber.awaitAll([first, second, idle])
expect(
exits.slice(0, 2).every((exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)),
).toBeTrue()
expect(exits.slice(2).every(Exit.isSuccess)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1)
expect(reasons).toEqual(["user"])
+63 -34
View File
@@ -897,30 +897,38 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
])
})
const prepareTitleGeneration = Effect.gen(function* () {
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
})
const watchRename = Effect.fnUntraced(function* (sessionID: Session.ID) {
const bus = yield* Bus.Service
return yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
})
describe("SessionRunnerLLM", () => {
it.effect("generates the title while the first model step is still running", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* prepareTitleGeneration
yield* admit(session, "First prompt")
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
const renamed = yield* watchRename(sessionID)
const runner = yield* SessionRunner.Service
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Fiber.join(renamed)
@@ -930,19 +938,46 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("coalesces title generation while a request is active", () =>
Effect.gen(function* () {
const session = yield* setup
yield* prepareTitleGeneration
const titleStarted = yield* Deferred.make<void>()
const releaseTitle = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
yield* admit(session, "First prompt")
yield* TestLLM.push(
Stream.unwrap(
Deferred.succeed(titleStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseTitle)),
Effect.as(Stream.fromIterable(TestLLM.text("Generated title", "text-title"))),
),
),
TestLLM.text("First response", "text-first"),
TestLLM.text("Second response", "text-second"),
)
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(titleStarted).pipe(Effect.timeout("5 seconds"))
expect(requests[0]?.system.map((part) => part.text)).toContain("Generate a title.")
yield* Fiber.join(first)
yield* admit(session, "Second prompt")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
const renamed = yield* watchRename(sessionID)
yield* Deferred.succeed(releaseTitle, undefined)
yield* Fiber.join(renamed)
expect((yield* session.get(sessionID)).title).toBe("Generated title")
}).pipe(Effect.ensuring(Deferred.succeed(releaseTitle, undefined)))
}),
)
it.effect("retries title generation from the first prompt after title and execution failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* prepareTitleGeneration
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
@@ -961,13 +996,7 @@ describe("SessionRunnerLLM", () => {
yield* Effect.yieldNow
expect((yield* session.get(sessionID)).title).toBeUndefined()
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const renamed = yield* watchRename(sessionID)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Generated title", "text-title"),
+1 -1
View File
@@ -47,7 +47,7 @@ test("Effect tool schemas use exact optional keys and flatten compatible constra
type: "object",
properties: {
offset: { type: "integer", minimum: 0 },
code: { type: "string", allOf: [{ pattern: "^a" }, { pattern: "z$" }] },
code: { type: "string", pattern: "^a", allOf: [{ pattern: "z$" }] },
},
required: ["code"],
additionalProperties: false,
+26 -24
View File
@@ -856,31 +856,33 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live("does not retain removed running shells in exit order", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
withSession(tmp.path, () =>
Effect.gen(function* () {
const shell = yield* Shell.Service
yield* Effect.forEach(Array.from({ length: 26 }), () =>
Effect.gen(function* () {
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
yield* shell.remove(info.id)
yield* Effect.sleep(Duration.millis(10))
}),
)
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
expect(settled._tag).toBe("Some")
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
// Retention is platform-independent; starting 27 PowerShell processes serially only exercises
// Windows process startup and can exceed Bun's default per-test timeout.
if (!isWindows) {
it.live("does not retain removed running shells in exit order", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
withSession(tmp.path, () =>
Effect.gen(function* () {
const shell = yield* Shell.Service
yield* Effect.forEach(Array.from({ length: 26 }), () =>
Effect.gen(function* () {
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
yield* shell.remove(info.id)
yield* Effect.sleep(Duration.millis(10))
}),
)
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
expect(settled._tag).toBe("Some")
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("settles a shell terminated by an external signal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -30,6 +30,7 @@ export const appHandlers = AppRpcs.toLayer(
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppAwaitInitialization: () => background.connection,
AppReconnectService: () => background.reconnect,
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
@@ -106,6 +106,7 @@ export const IpcServerProtocolLive = Layer.unwrap(
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
supportsNotifications: true,
}
}),
),
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { BackgroundServiceState } from "./background-service-state"
test("new consumers receive the latest reconnected service", async () => {
const initial = { url: "http://127.0.0.1:4100", username: "opencode", password: "first" }
const replacement = { url: "http://127.0.0.1:4200", username: "opencode", password: "second" }
const service = await Effect.runPromise(
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
)
expect(await Effect.runPromise(service.connection)).toEqual(initial)
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
})
@@ -0,0 +1,16 @@
export * as BackgroundServiceState from "./background-service-state"
import { Effect, Exit, Ref } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
readonly initial: Effect.Effect<ServerReadyData, unknown>
readonly reconnect: Effect.Effect<ServerReadyData>
}) {
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
const current = yield* Ref.make<Exit.Exit<ServerReadyData, unknown>>(yield* options.initial.pipe(Effect.exit))
return {
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
}
})
@@ -1,12 +1,14 @@
import { app } from "electron"
import { Context, Effect, Exit, Layer, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
export * as BackgroundService from "./background-service"
export interface Interface {
readonly connection: Effect.Effect<ServerReadyData>
readonly reconnect: Effect.Effect<ServerReadyData>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
@@ -14,22 +16,24 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const result = yield* start().pipe(Effect.exit)
return Service.of({
connection: Exit.isSuccess(result)
? Effect.succeed(result.value)
: Effect.failCause(result.cause).pipe(Effect.orDie),
})
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path | DesktopCli.Service>()
return Service.of(
yield* BackgroundServiceState.make({
initial: connect("initial").pipe(Effect.provide(context)),
reconnect: connect("reconnect").pipe(Effect.provide(context), Effect.orDie),
}),
)
}),
)
const start = Effect.fn("BackgroundService.start")(function* () {
const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") {
yield* Effect.logInfo("starting v2 background service")
const path = yield* Path.Path
const desktopCli = yield* DesktopCli.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const cli = yield* desktopCli.resolve
const version = mode === "initial" ? cli.version : undefined
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode-ai/client/service"))
const service = yield* Effect.tryPromise(() =>
@@ -38,7 +42,7 @@ const start = Effect.fn("BackgroundService.start")(function* () {
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
@@ -49,10 +53,10 @@ const start = Effect.fn("BackgroundService.start")(function* () {
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
yield* Effect.logInfo("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
version,
...endpoint(url.origin),
})
if (isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
return {
url: url.origin,
username: service.auth.username,
@@ -22,6 +22,7 @@ export type UpdaterAPI = {
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
+1
View File
@@ -24,6 +24,7 @@ const updaterHandler = (state: UpdaterState) => {
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
@@ -24,7 +24,7 @@ import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { initializationData } from "./startup/initialization"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
import { LoadingSplash } from "./startup/splash"
import { getLastActiveUrl } from "./window/route-storage"
@@ -75,7 +75,7 @@ function DesktopWindow(props: {
onRoute: (route: LayoutRoute) => void
}) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
@@ -97,11 +97,8 @@ function DesktopWindow(props: {
displayName: language.t("desktop.server.local"),
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
http: sidecarHttp(data),
reconnect: createSidecarResolver({ api: props.api, current: sidecar, update: setSidecar }),
})
}
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { initializationData } from "./initialization"
import { createSidecarResolver, initializationData } from "./initialization"
describe("desktop renderer initialization", () => {
test("throws the original initialization error before rendering server providers", () => {
@@ -45,4 +45,48 @@ describe("desktop renderer initialization", () => {
expect(caught.message).toBe("")
expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
})
test("refreshes the managed sidecar endpoint", async () => {
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "next" }
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: async () => sidecar },
current: () => undefined,
update: (next) => updates.push(next),
})
expect(await resolve(new AbortController().signal)).toEqual(sidecar)
expect(updates).toEqual([sidecar])
})
test("keeps the current sidecar when reconnection resolves the same endpoint", async () => {
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "same" }
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: async () => ({ ...sidecar }) },
current: () => sidecar,
update: (next) => updates.push(next),
})
expect(await resolve(new AbortController().signal)).toEqual(sidecar)
expect(updates).toEqual([])
})
test("does not publish a sidecar resolved after cancellation", async () => {
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "next" }
const pending = Promise.withResolvers<typeof sidecar>()
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: () => pending.promise },
current: () => undefined,
update: (next) => updates.push(next),
})
const abort = new AbortController()
const result = resolve(abort.signal)
abort.abort()
pending.resolve(sidecar)
await expect(result).rejects.toBe(abort.signal.reason)
expect(updates).toEqual([])
})
})
@@ -1,8 +1,38 @@
import type { ElectronAPI } from "../api-types"
type SidecarData = Awaited<ReturnType<ElectronAPI["awaitInitialization"]>>
export function initializationData<A>(state: (() => A | undefined) & { error: unknown }) {
if (state.error !== undefined) throw markLocalServerStartup(state.error)
return state()
}
export function sidecarHttp(data: SidecarData) {
return {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
}
export function createSidecarResolver(input: {
api: Pick<ElectronAPI, "reconnectService">
current: () => SidecarData | undefined
update: (data: SidecarData) => void
}) {
return async (signal: AbortSignal) => {
if (signal.aborted) throw signal.reason
const next = await input.api.reconnectService()
if (signal.aborted) throw signal.reason
if (!sameSidecar(input.current(), next)) input.update(next)
return sidecarHttp(next)
}
}
function sameSidecar(current: SidecarData | undefined, next: SidecarData) {
return current?.url === next.url && current.username === next.username && current.password === next.password
}
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
Object.defineProperty(failure, "localServerStartup", { value: true })
@@ -8,6 +8,7 @@ const ServerReadyData = Schema.Struct({
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
success: Schema.Array(Schema.String),
})
@@ -56,6 +57,7 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppReconnectService,
AppConsumeInitialDeepLinks,
AppGetDefaultServerUrl,
AppSetDefaultServerUrl,
+1 -1
View File
@@ -52,7 +52,7 @@
"typescript": "catalog:"
},
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.110"
"@effect/platform-node-shared": "4.0.0-rc.111"
},
"peerDependencies": {
"effect": "catalog:"
+2
View File
@@ -2,6 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
@@ -13,6 +14,7 @@ export interface ToolDraft {
export interface ToolHooks {
readonly "execute.before": {
readonly tool: string
readonly inputSchema: JsonSchema.JsonSchema
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
+2
View File
@@ -5,6 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
@@ -30,6 +31,7 @@ interface ToolDraft {
interface ToolHooks {
readonly "execute.before": {
readonly tool: string
readonly inputSchema: JsonSchema.JsonSchema
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
+4
View File
@@ -1,7 +1,9 @@
import type {
AgentInfo,
CommandInfo,
FormCancelInput,
FormInfo,
FormReplyInput,
IntegrationInfo,
LocationRef,
McpResource,
@@ -91,6 +93,8 @@ export interface Data {
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
sync(sessionID: string, location?: LocationRef): Promise<void>
invalidate(sessionID: string, location?: LocationRef): void
reply(input: FormReplyInput, location?: LocationRef): Promise<void>
cancel(input: FormCancelInput, location?: LocationRef): Promise<void>
}
}
readonly project: {
+3 -1
View File
@@ -1282,7 +1282,9 @@ function App(props: { pair?: DialogPairCredentials }) {
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
onMouseUp={
copyOnSelectEnabled() ? (event) => Selection.copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
}
>
<box
flexGrow={1}
+4 -4
View File
@@ -1028,7 +1028,7 @@ export function Prompt(props: PromptProps) {
return
}
const item = history.move(props.sessionID, -1, input.plainText)
const item = history.move(-1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
@@ -1067,7 +1067,7 @@ export function Prompt(props: PromptProps) {
return
}
const item = history.move(props.sessionID, 1, input.plainText)
const item = history.move(1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
@@ -1260,7 +1260,7 @@ export function Prompt(props: PromptProps) {
}
const target = sessionID
history.append(target, entry)
history.append(entry)
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
if (setup) void setup.gate.then(send).catch(setup.recover)
@@ -1562,7 +1562,7 @@ export function Prompt(props: PromptProps) {
(store.prompt.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0
) {
history.append(props.sessionID, {
history.append({
...store.prompt,
mode: store.mode,
})
+15 -31
View File
@@ -26,11 +26,6 @@ export type PromptPartRef = {
index: number
}
type PromptHistoryEntry = {
sessionID: string | undefined
prompt: PromptInfo
}
export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], skills: [], pasted: [] })
export const MAX_HISTORY_ENTRIES = 50
@@ -41,19 +36,12 @@ export function parsePromptHistory(text: string) {
.filter(Boolean)
.map((line) => {
try {
const value: unknown = JSON.parse(line)
const input = value && typeof value === "object" ? (value as Record<string, unknown>) : undefined
const prompt = parsePromptInfo(input?.prompt ?? value)
if (!prompt) return
return {
sessionID: typeof input?.sessionID === "string" ? input.sessionID : undefined,
prompt,
}
return parsePromptInfo(JSON.parse(line))
} catch {
return undefined
}
})
.filter((line): line is PromptHistoryEntry => line !== undefined)
.filter((line): line is PromptInfo => line !== undefined)
.slice(-MAX_HISTORY_ENTRIES)
}
@@ -83,28 +71,24 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
})
const [store, setStore] = createStore({ history: [] as PromptHistoryEntry[] })
const indices = new Map<string | undefined, number>()
const [store, setStore] = createStore({ index: 0, history: [] as PromptInfo[] })
return {
move(sessionID: string | undefined, direction: 1 | -1, input: string) {
const items = store.history.filter((entry) => entry.sessionID === sessionID)
if (!items.length) return undefined
const index = indices.get(sessionID) ?? 0
const current = items.at(index)?.prompt
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.text !== input && input.length) return
const next = index + direction
if (Math.abs(next) > items.length || next > 0) return
indices.set(sessionID, next)
const next = store.index + direction
if (Math.abs(next) > store.history.length || next > 0) return
setStore("index", next)
if (next === 0) return emptyPrompt()
return items.at(next)?.prompt
return store.history.at(next)
},
append(sessionID: string | undefined, item: PromptInfo) {
const entry = { sessionID, prompt: structuredClone(unwrap(item)) }
const previous = store.history.findLast((item) => item.sessionID === sessionID)
if (isDuplicateEntry(previous?.prompt, entry.prompt)) {
indices.set(sessionID, 0)
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
return
}
let trimmed = false
@@ -115,9 +99,9 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
indices.set(sessionID, 0)
if (trimmed) {
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
+13 -33
View File
@@ -12,8 +12,7 @@ import {
import open from "open"
import { useTheme, useThemes } from "../../context/theme"
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
import { useData, type FormWithLocation } from "../../context/data"
import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
@@ -41,22 +40,8 @@ function truncate(label: string, max: number) {
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
}
function requestOptions(form: FormWithLocation) {
if (form.sessionID !== "global" || !form.location) return undefined
return {
headers: {
"x-opencode-directory": encodeURIComponent(form.location.directory),
...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}),
},
}
}
export function FormPrompt(props: {
form: FormWithLocation
onReply?: (answer: FormAnswer) => void | Promise<void>
onCancel?: () => void | Promise<void>
}) {
const client = useClient()
export function FormPrompt(props: { form: FormWithLocation }) {
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
const themeMode = themes.mode
@@ -258,16 +243,9 @@ export function FormPrompt(props: {
}
function reply(answer: FormAnswer) {
void Promise.resolve()
.then(() =>
props.onReply
? props.onReply(answer)
: client.api.form.reply(
{ sessionID: props.form.sessionID, formID: props.form.id, answer },
requestOptions(props.form),
),
)
.catch((error: unknown) => setStore("error", errorMessage(error)))
void data.session.form
.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer }, props.form.location)
.catch(showError)
}
function replySingle(field: FormAnswerField, value: FormValue) {
@@ -457,11 +435,13 @@ export function FormPrompt(props: {
}
function cancel() {
if (props.onCancel) {
void props.onCancel()
return
}
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
void data.session.form
.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, props.form.location)
.catch(showError)
}
function showError(error: unknown) {
setStore("error", errorMessage(error))
}
function openExternal() {
+3 -13
View File
@@ -7,6 +7,7 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config"
import { copy, copyOnSelectRelease } from "../util/selection"
export type DialogSize = "medium" | "large" | "xlarge"
@@ -210,17 +211,6 @@ export function DialogProvider(props: ParentProps) {
const copyOnSelectEnabled = () =>
(config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select"
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error),
)
renderer.clearSelection()
return true
}
return (
<ctx.Provider value={value}>
{props.children}
@@ -231,11 +221,11 @@ export function DialogProvider(props: ParentProps) {
if (copyOnSelectEnabled()) return
if (evt.button !== MouseButton.RIGHT) return
if (!copySelection()) return
if (!copy(renderer, toast, clipboard)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={copyOnSelectEnabled() ? copySelection : undefined}
onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
+12 -1
View File
@@ -23,6 +23,16 @@ type SelectionKeyEvent = {
stopPropagation: () => void
}
export function copyOnSelectRelease(
event: { isDragging?: boolean },
renderer: Renderer,
toast: Toast,
clipboard: ClipboardService,
): boolean {
if (!event.isDragging) return false
return copy(renderer, toast, clipboard)
}
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
const selection = renderer.getSelection()
if (!selection) return false
@@ -39,7 +49,8 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
// Keep the highlight. clearSelection() also resets OpenTUI's click
// counter, so clearing here would turn a triple-click into a new single-click.
return true
}
+105 -18
View File
@@ -3,7 +3,8 @@ import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import type { FormWithLocation } from "../../../src/context/data"
import { onMount, Show } from "solid-js"
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
import { ClientProvider } from "../../../src/context/client"
import { ThemeProvider } from "../../../src/context/theme"
import { Keymap } from "../../../src/context/keymap"
@@ -12,7 +13,7 @@ import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
async function mountForm(
root: string,
@@ -20,23 +21,17 @@ async function mountForm(
fields?: FormWithLocation["fields"],
height = 20,
clipboardText?: string,
response?: { reply?: 404 | 409; cancel?: 404 | 409; syncFailure?: boolean },
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const replies: unknown[] = []
const cancellations: unknown[] = []
const copied: string[] = []
let terminal = false
let formLists = 0
const events = createEventStream()
const transport = createFetch(
(url, request) =>
url.pathname === "/api/session/ses_test/form/frm_test/reply"
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
})
: undefined,
events,
)
const config = createTuiResolvedConfig()
const form = {
id: "frm_test",
@@ -51,8 +46,44 @@ async function mountForm(
},
],
} satisfies FormWithLocation
const failure = (status: 404 | 409) => {
terminal = true
return json(
{
_tag: status === 404 ? "FormNotFoundError" : "FormAlreadySettledError",
id: form.id,
message: status === 404 ? `Form not found: ${form.id}` : `Form already settled: ${form.id}`,
},
{ status },
)
}
const transport = createFetch((url, request) => {
if (url.pathname === "/api/session/ses_test/form" && request.method === "GET")
return response?.syncFailure && formLists++ > 0
? json({ message: "Could not refresh forms" }, { status: 500 })
: json({ data: terminal ? [] : [form] })
if (url.pathname === "/api/session/ses_test/form/frm_test/reply")
return request.json().then((answer) => {
replies.push(answer)
return response?.reply ? failure(response.reply) : new Response(null, { status: 204 })
})
if (url.pathname === "/api/session/ses_test/form/frm_test/cancel") {
cancellations.push(true)
return response?.cancel ? failure(response.cancel) : new Response(null, { status: 204 })
}
}, events)
const { FormPrompt } = await import("../../../src/routes/session/form")
function CurrentForm() {
const data = useData()
onMount(() => void data.session.form.sync(form.sessionID))
return (
<Show when={data.session.form.list(form.sessionID)?.[0]} keyed fallback={<text>Composer ready</text>}>
{(current) => <FormPrompt form={current} />}
</Show>
)
}
function Harness() {
return (
<TestTuiContexts
@@ -75,11 +106,11 @@ async function mountForm(
<ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
<DataProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>{response ? <CurrentForm /> : <FormPrompt form={form} />}</ToastProvider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
@@ -90,7 +121,63 @@ async function mountForm(
const app = await testRender(() => <Harness />, { width, height, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Authorization required"))
return { app, copied, replies }
return { app, cancellations, copied, replies }
}
function mountRecoveringForm(root: string, response: { reply?: 404 | 409; cancel?: 404 | 409; syncFailure?: boolean }) {
return mountForm(
root,
80,
[{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }] }],
20,
undefined,
response,
)
}
test("restores the composer when terminal-form revalidation fails", async () => {
await using tmp = await tmpdir()
const prompt = await mountRecoveringForm(tmp.path, { reply: 404, syncFailure: true })
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toHaveLength(1)
} finally {
prompt.app.renderer.destroy()
}
})
for (const status of [404, 409] as const) {
test(`restores the composer after a terminal ${status} reply`, async () => {
await using tmp = await tmpdir()
const prompt = await mountRecoveringForm(tmp.path, { reply: status })
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toHaveLength(1)
expect(prompt.app.captureCharFrame()).not.toContain("Form not found")
expect(prompt.app.captureCharFrame()).not.toContain("Form already settled")
} finally {
prompt.app.renderer.destroy()
}
})
test(`restores the composer after a terminal ${status} cancellation`, async () => {
await using tmp = await tmpdir()
const prompt = await mountRecoveringForm(tmp.path, { cancel: status })
try {
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.cancellations).toHaveLength(1)
expect(prompt.app.captureCharFrame()).not.toContain("Form not found")
expect(prompt.app.captureCharFrame()).not.toContain("Form already settled")
} finally {
prompt.app.renderer.destroy()
}
})
}
test("requires explicit acknowledgement before submitting an external field", async () => {
@@ -11,28 +11,27 @@ test("down rejects at the newest history item with an empty prompt", async () =>
await using tmp = await tmpdir()
const setup = await renderHistory(tmp.path)
try {
setup.history.append("session-a", { text: "previous", files: [], agents: [], pasted: [] })
setup.history.append({ text: "previous", files: [], agents: [], pasted: [] })
expect(setup.history.move("session-a", 1, "")).toBeUndefined()
expect(setup.history.move("session-a", -1, "")?.text).toBe("previous")
expect(setup.history.move("session-a", 1, "previous")?.text).toBe("")
expect(setup.history.move(1, "")).toBeUndefined()
expect(setup.history.move(-1, "")?.text).toBe("previous")
expect(setup.history.move(1, "previous")?.text).toBe("")
} finally {
setup.app.renderer.destroy()
}
})
test("keeps independent prompt history and cursors for each session", async () => {
test("shares prompt history across sessions and the home composer", async () => {
await using tmp = await tmpdir()
const setup = await renderHistory(tmp.path)
try {
setup.history.append("session-a", { text: "a-one", files: [], agents: [], pasted: [] })
setup.history.append("session-b", { text: "b-one", files: [], agents: [], pasted: [] })
setup.history.append("session-a", { text: "a-two", files: [], agents: [], pasted: [] })
setup.history.append({ text: "a-one", files: [], agents: [], pasted: [] })
setup.history.append({ text: "b-one", files: [], agents: [], pasted: [] })
setup.history.append({ text: "a-two", files: [], agents: [], pasted: [] })
expect(setup.history.move("session-a", -1, "")?.text).toBe("a-two")
expect(setup.history.move("session-b", -1, "")?.text).toBe("b-one")
expect(setup.history.move("session-a", -1, "a-two")?.text).toBe("a-one")
expect(setup.history.move("session-b", 1, "b-one")?.text).toBe("")
expect(setup.history.move(-1, "")?.text).toBe("a-two")
expect(setup.history.move(-1, "a-two")?.text).toBe("b-one")
expect(setup.history.move(-1, "b-one")?.text).toBe("a-one")
} finally {
setup.app.renderer.destroy()
}
@@ -45,8 +44,7 @@ test("keeps legacy unscoped history on the home composer", async () => {
try {
expect((await waitForHistory(setup.history))?.text).toBe("legacy")
expect(setup.history.move("session-a", -1, "")).toBeUndefined()
expect(setup.history.move(undefined, 1, "legacy")?.text).toBe("")
expect(setup.history.move(1, "legacy")?.text).toBe("")
} finally {
setup.app.renderer.destroy()
}
@@ -76,7 +74,7 @@ async function renderHistory(root: string, persisted?: string) {
async function waitForHistory(history: ReturnType<typeof usePromptHistory>) {
for (const _ of Array.from({ length: 100 })) {
const item = history.move(undefined, -1, "")
const item = history.move(-1, "")
if (item) return item
await Bun.sleep(1)
}
+3 -8
View File
@@ -11,8 +11,8 @@ const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({
describe("prompt history", () => {
test("recovers valid JSONL entries around corruption", () => {
expect(parsePromptHistory(`${JSON.stringify(entry("one"))}\nnot-json\n${JSON.stringify(entry("two"))}\n`)).toEqual([
{ sessionID: undefined, prompt: entry("one") },
{ sessionID: undefined, prompt: entry("two") },
entry("one"),
entry("two"),
])
})
@@ -26,7 +26,7 @@ describe("prompt history", () => {
).join("\n")
const result = parsePromptHistory(input)
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
expect(result[0]?.prompt.text).toBe("5")
expect(result[0]?.text).toBe("5")
})
test("dedupes only identical consecutive entries", () => {
@@ -56,11 +56,6 @@ describe("prompt history", () => {
},
])
expect(parsePromptHistory(JSON.stringify(value))).toEqual([{ sessionID: undefined, prompt: value }])
})
test("preserves the session scope", () => {
const value = { sessionID: "session-a", prompt: entry("hello") }
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
})
})
@@ -0,0 +1,59 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender, useRenderer } from "@opentui/solid"
import { useClipboard } from "../../src/context/clipboard"
import { copyOnSelectRelease } from "../../src/util/selection"
import { TestTuiContexts } from "../fixture/tui-environment"
function CopyOnSelectText() {
const renderer = useRenderer()
const clipboard = useClipboard()
const toast = {
show: () => {},
error: () => {},
}
return (
<box onMouseUp={(event) => copyOnSelectRelease(event, renderer, toast, clipboard)}>
<text>alpha beta gamma</text>
</box>
)
}
test("copy-on-select keeps a word highlight so a third click can select the line", async () => {
const writes: string[] = []
const app = await testRender(
() => (
<TestTuiContexts
clipboard={{
async read() {
return undefined
},
async write(text) {
writes.push(text)
},
}}
>
<CopyOnSelectText />
</TestTuiContexts>
),
{ width: 20, height: 2 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("beta"))
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("")
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
expect(writes).toEqual(["beta"])
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("alpha beta gamma")
expect(writes).toEqual(["beta", "alpha beta gamma"])
} finally {
app.renderer.destroy()
}
})
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test"
import { copy, copyOnSelectRelease } from "../../src/util/selection"
function renderer() {
return {
getSelection: () => ({
getSelectedText: () => "beta",
selectedRenderables: [],
}),
clearSelection: () => {},
}
}
test("copy writes selected text without clearing the highlight", () => {
let cleared = false
const copied = copy(
{
getSelection: () => ({
getSelectedText: () => "beta",
selectedRenderables: [],
}),
clearSelection: () => {
cleared = true
},
},
{ show: () => {}, error: () => {} },
{
async read() {
return undefined
},
async write() {},
},
)
expect(copied).toBe(true)
expect(cleared).toBe(false)
})
test("copy-on-select ignores a later non-drag release", () => {
const writes: string[] = []
const clipboard = {
async read() {
return undefined
},
async write(value: string) {
writes.push(value)
},
}
const toast = { show: () => {}, error: () => {} }
expect(copyOnSelectRelease({}, renderer(), toast, clipboard)).toBe(false)
expect(copyOnSelectRelease({ isDragging: false }, renderer(), toast, clipboard)).toBe(false)
expect(copyOnSelectRelease({ isDragging: true }, renderer(), toast, clipboard)).toBe(true)
expect(writes).toEqual(["beta"])
})