mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 10:36:19 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ec50ad9b | ||
|
|
7da10aa65d | ||
|
|
d80b0a1e7e | ||
|
|
481125f617 | ||
|
|
59d0d64a6b | ||
|
|
e0cdf1ed09 | ||
|
|
9e50d76416 | ||
|
|
771c0f5850 | ||
|
|
b1a0ef91bb | ||
|
|
2e67cee75b | ||
|
|
890735c1d7 | ||
|
|
049f0b0c3b | ||
|
|
6020f36862 | ||
|
|
0d24ebdbbe | ||
|
|
b22c182406 | ||
|
|
c7f2f367e3 | ||
|
|
ccd2135e7a | ||
|
|
c7c22b9d7e | ||
|
|
67e99993f5 |
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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 () => {},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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"])
|
||||
})
|
||||
Reference in New Issue
Block a user