mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 02:26:24 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019466c095 | ||
|
|
374e36a8e3 | ||
|
|
75b76f34bd | ||
|
|
9018aa700a | ||
|
|
51528c33e9 | ||
|
|
8228b3093b |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -48,7 +48,6 @@ jobs:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
id: setup-bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Test Effect simplification rules
|
||||
@@ -86,7 +85,7 @@ jobs:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always() && steps.setup-bun.outcome == 'success'
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
@@ -102,7 +101,7 @@ jobs:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: always() && steps.setup-bun.outcome == 'success'
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
|
||||
@@ -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.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"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.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
},
|
||||
"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.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",
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -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.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"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.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/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.110", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.110" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-8Uum1ikIAK2DjATeLseS71HZKVI/dduFYypvMhiO68RTli1xq9yaoTI1Y0IThPn2sI0i86ZdpFaKQx1hdw2tWw=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-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": ["@effect/platform-node@4.0.0-rc.110", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.110", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110", "redis": ">=5.0.0 <7.0.0" } }, "sha512-poj6VxTc2kRDowUHgjGXAZL2nWHTyGi/18607jK+pV9A9CH/wqZ4NeYj38rij95j5SiZ3U7Y1iOsk2Vfnr4GEw=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.110", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.110", "", { "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-Lam3gY1xszjQS/cebdLbWbtR21FtQI4ke7QHqbBQ6AyVJF0CGUtS/ePL9zNq6wHNmJ95FUDGS6W+Qx/l6RPSzA=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -3853,7 +3853,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"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=="],
|
||||
"effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -6173,8 +6173,6 @@
|
||||
|
||||
"@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-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
|
||||
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
|
||||
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
|
||||
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
|
||||
"x86_64-linux": "sha256-P8AZ2THuXiET4hiSYTwGGdTgbm2l3XGZGJxQwg6lusE=",
|
||||
"aarch64-linux": "sha256-73LxDeDPQspY4Bn/gbGU43j/k7gBZ5ywMMpUjDl3elg=",
|
||||
"aarch64-darwin": "sha256-sc9YyTAFlbWxlFb9vwFYLs6IYd3SRoju9mQufzeQdd8=",
|
||||
"x86_64-darwin": "sha256-nnk637DELjzGB3t9aLDYbHz5h3Dd3nvWcWGdaQk8yPM="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -39,10 +39,10 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@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",
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -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.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"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` / `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` / `local_shell_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,7 +41,6 @@ const SSE_EVENTS = new Set([
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
@@ -1005,13 +1004,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
}),
|
||||
)
|
||||
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, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, 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,15 +82,10 @@ 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: optionalNull(Schema.Boolean),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiInlineDataPart = Schema.Struct({
|
||||
@@ -103,11 +98,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: optionalNull(Schema.String),
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
@@ -127,8 +122,8 @@ const GeminiContentPart = Schema.Union([
|
||||
])
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||
role: Schema.Literals(["user", "model"]),
|
||||
parts: Schema.Array(GeminiContentPart),
|
||||
})
|
||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||
|
||||
@@ -191,33 +186,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
|
||||
const GeminiUsage = Schema.Struct({
|
||||
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||
promptTokenCount: optionalNull(Schema.Number),
|
||||
candidatesTokenCount: optionalNull(Schema.Number),
|
||||
totalTokenCount: optionalNull(Schema.Number),
|
||||
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),
|
||||
})
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: optionalNull(GeminiContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
content: Schema.optional(GeminiContent),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: optionalNull(Schema.String),
|
||||
blockReasonMessage: optionalNull(Schema.String),
|
||||
safetyRatings: optionalNull(Schema.Unknown),
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
@@ -310,8 +305,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
|
||||
}
|
||||
@@ -402,8 +397,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 })
|
||||
}
|
||||
|
||||
@@ -493,25 +488,21 @@ 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
|
||||
// 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)
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, 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 = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: promptTokens,
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
@@ -546,10 +537,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
// `?? 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 promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
@@ -598,7 +586,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.
|
||||
@@ -637,8 +625,7 @@ 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.
|
||||
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||
const supplied = part.functionCall.id ?? undefined
|
||||
const supplied = part.functionCall.id
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
@@ -655,7 +642,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata:
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
part.thoughtSignature === undefined ? undefined : googleMetadata({ thoughtSignature: part.thoughtSignature }),
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
@@ -429,7 +428,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.dataUrl }),
|
||||
...(url ? { file_url: url } : { file_data: media.base64 }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
@@ -581,7 +580,8 @@ 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,19 +1105,11 @@ 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,
|
||||
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1134,8 +1126,7 @@ 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,9 +156,6 @@ 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({
|
||||
@@ -207,16 +204,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
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 OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
@@ -517,17 +509,6 @@ 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 {
|
||||
@@ -551,15 +532,12 @@ 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
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
@@ -603,18 +581,11 @@ 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 ??
|
||||
(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 cached = usage.prompt_tokens_details?.cached_tokens ?? 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))
|
||||
@@ -720,11 +691,8 @@ 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,12 +134,13 @@ const HOSTED_TOOLS = {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
computer_use_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,11 +28,7 @@ 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] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -42,7 +38,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
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, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
|
||||
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,13 +1,9 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
|
||||
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,4 +4,10 @@ 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", providerOptions: { serviceTier: "future-tier" } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { serviceTier: "premium" },
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@ 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,73 +1492,6 @@ 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,7 +342,6 @@ describe("OpenAI Chat route", () => {
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
||||
],
|
||||
tools: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
@@ -78,45 +78,6 @@ 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({
|
||||
@@ -153,12 +114,7 @@ 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 },
|
||||
],
|
||||
})
|
||||
}),
|
||||
@@ -233,7 +189,6 @@ describe("Open Responses-compatible route", () => {
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
serviceTier: "provider-tier",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
@@ -258,7 +213,6 @@ 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,11 +188,13 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through provider-defined service tiers", () =>
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.service_tier).toBe("scale")
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1271,7 +1273,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1298,7 +1300,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: base64 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1331,7 +1333,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1356,7 +1358,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2650,47 +2652,6 @@ 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 = {
|
||||
@@ -2804,7 +2765,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2835,7 +2796,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2860,7 +2821,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
file_data: "AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3068,14 +3029,14 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies a detail-free error event as a transient provider failure", () =>
|
||||
it.effect("falls back to the raw payload when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
}),
|
||||
|
||||
@@ -131,11 +131,8 @@ 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")).toBeHidden()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
@@ -145,7 +142,6 @@ 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)
|
||||
|
||||
@@ -165,18 +161,14 @@ 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 expectSideSlideSettled(page, 3)
|
||||
await expectSideMotionSettled(page)
|
||||
await expectNoHeightMotion(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
@@ -217,8 +209,6 @@ 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 }[]
|
||||
@@ -235,8 +225,6 @@ async function installMotionProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe: MotionProbe = {
|
||||
widths: 0,
|
||||
widthEnds: 0,
|
||||
horizontalScrolls: [],
|
||||
reviewWidths: [],
|
||||
paintGaps: [],
|
||||
terminalContentSizes: [],
|
||||
@@ -311,16 +299,10 @@ 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
|
||||
})
|
||||
}
|
||||
@@ -338,10 +320,11 @@ async function resetHeightMotions(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
async function expectSideSlideSettled(page: Page, count: number) {
|
||||
async function expectSideMotionSettled(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoHeightMotion(page: Page) {
|
||||
@@ -351,40 +334,6 @@ 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, createServerTransport } from "./client"
|
||||
import { createOpenCodeEventSource } from "./client"
|
||||
|
||||
const permission = {
|
||||
id: "evt_permission",
|
||||
@@ -84,39 +84,3 @@ 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,12 +72,11 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const pty = createPtyClient(api, { url: server.http.url })
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
@@ -86,7 +85,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: transport.url, managed: !!reconnect, ...data })
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -94,48 +93,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
get url() {
|
||||
return transport.url
|
||||
},
|
||||
get api() {
|
||||
return transport.api
|
||||
},
|
||||
get pty() {
|
||||
return transport.pty
|
||||
},
|
||||
url: server.http.url,
|
||||
api,
|
||||
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"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
|
||||
{ variant: "base" }
|
||||
// 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({ preventScroll: true })
|
||||
textarea.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
terminal.focus({ preventScroll: true })
|
||||
terminal.focus()
|
||||
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="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div ref={screen.panel.ref} class="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,7 +241,6 @@ 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
|
||||
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
|
||||
focus()
|
||||
setTimeout(focus, 0)
|
||||
t.focus()
|
||||
t.textarea?.focus()
|
||||
setTimeout(() => t.textarea?.focus(), 0)
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,7 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
LocationGetOutput,
|
||||
@@ -39,12 +37,7 @@ 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 {
|
||||
isFormAlreadySettledError,
|
||||
isFormNotFoundError,
|
||||
isPermissionNotFoundError,
|
||||
type SessionPromptInput,
|
||||
} from "../promise"
|
||||
import { 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"
|
||||
@@ -127,16 +120,6 @@ 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>()
|
||||
@@ -245,32 +228,6 @@ 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]
|
||||
@@ -1041,7 +998,12 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "form.replied":
|
||||
case "form.cancelled":
|
||||
removeForm(event.data.sessionID, event.data.id, event.location)
|
||||
setStore(
|
||||
"session",
|
||||
"form",
|
||||
event.data.sessionID,
|
||||
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1458,12 +1420,6 @@ 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 Bun.write(snapshot, await formatJson(await Bun.file(path.join(incremental, name, "snapshot.json")).text()))
|
||||
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||
}
|
||||
|
||||
await fs.mkdir(full)
|
||||
@@ -192,19 +192,6 @@ 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")}
|
||||
|
||||
@@ -197,7 +197,7 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.preferred()
|
||||
const shell = yield* services.shell.resolve({ preference: "configured" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -22,11 +23,13 @@ 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>
|
||||
@@ -135,6 +138,25 @@ 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,
|
||||
@@ -239,6 +261,15 @@ 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,6 +9,8 @@ export type OpenAIResponsesInputItem =
|
||||
| OpenAIResponsesFunctionCall
|
||||
| OpenAIResponsesFunctionCallOutput
|
||||
| OpenAIResponsesComputerCall
|
||||
| OpenAIResponsesLocalShellCall
|
||||
| OpenAIResponsesLocalShellCallOutput
|
||||
| OpenAIResponsesReasoning
|
||||
| OpenAIResponsesItemReference
|
||||
| OpenAIResponsesMcpApprovalResponse
|
||||
@@ -67,6 +69,26 @@ 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
|
||||
@@ -177,6 +199,9 @@ export type OpenAIResponsesTool =
|
||||
quality: "auto" | "low" | "medium" | "high" | undefined
|
||||
size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined
|
||||
}
|
||||
| {
|
||||
type: "local_shell"
|
||||
}
|
||||
|
||||
export type OpenAIResponsesReasoning = {
|
||||
type: "reasoning"
|
||||
|
||||
@@ -29,6 +29,7 @@ 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"),
|
||||
@@ -85,6 +86,20 @@ 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(),
|
||||
@@ -190,6 +205,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
systemMessageMode: modelConfig.systemMessageMode,
|
||||
fileIdPrefixes: this.config.fileIdPrefixes,
|
||||
store,
|
||||
hasLocalShellTool: hasOpenAITool("openai.local_shell"),
|
||||
})
|
||||
|
||||
warnings.push(...inputWarnings)
|
||||
@@ -446,6 +462,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
fileSearchCallItem,
|
||||
codeInterpreterCallItem,
|
||||
imageGenerationCallItem,
|
||||
localShellCallItem,
|
||||
z.object({
|
||||
type: z.literal("function_call"),
|
||||
call_id: z.string(),
|
||||
@@ -543,6 +560,22 @@ 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) {
|
||||
@@ -1060,6 +1093,27 @@ 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({
|
||||
@@ -1474,6 +1528,7 @@ const responseOutputItemDoneSchema = z.object({
|
||||
imageGenerationCallItem,
|
||||
webSearchCallItem,
|
||||
fileSearchCallItem,
|
||||
localShellCallItem,
|
||||
z.object({
|
||||
type: z.literal("computer_call"),
|
||||
id: z.string(),
|
||||
|
||||
@@ -70,6 +70,12 @@ 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({
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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,53 +273,28 @@ export const layer = (options?: Options) =>
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
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
|
||||
}
|
||||
let current: Credential.OAuth | undefined = found.value
|
||||
return MCPOAuth.provider({
|
||||
...base,
|
||||
// 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.
|
||||
// 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.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth || oauth.refresh !== presented) return
|
||||
current = undefined
|
||||
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 () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth) return undefined
|
||||
presented = oauth.refresh
|
||||
return MCPOAuth.toTokens(oauth)
|
||||
},
|
||||
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
|
||||
saveTokens: async (tokens) => {
|
||||
const previous = await readOAuthCredential()
|
||||
const value = MCPOAuth.toCredential({
|
||||
current = MCPOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
|
||||
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
|
||||
})
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
|
||||
await Effect.runPromise(credentials.update(credentialID, { value: current }))
|
||||
},
|
||||
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
saveCodeVerifier: async () => {},
|
||||
|
||||
@@ -164,7 +164,7 @@ const layer = () =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || (yield* shell.preferred())
|
||||
const command = input.command || (yield* shell.resolve({ preference: "configured" }))
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
|
||||
@@ -30,7 +30,13 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* with this execution's exit.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
/**
|
||||
* Resolves with the execution's exit as a success value. Success-valued on purpose:
|
||||
* completing a Deferred with an interrupted exit interrupts suspended waiters as it
|
||||
* resumes them, and can starve later waiters of their resume entirely
|
||||
* (Effect-TS/effect#7364). Joiners flatten the exit; idleness waiters just await.
|
||||
*/
|
||||
readonly done: Deferred.Deferred<Exit.Exit<void, E>>
|
||||
owner?: Fiber.Fiber<void>
|
||||
scope: Promotable
|
||||
pendingWake?: Promotable
|
||||
@@ -78,7 +84,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
|
||||
const start = (key: Key, force: boolean, scope: Promotable) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
|
||||
scope,
|
||||
stopping: false,
|
||||
}
|
||||
@@ -108,7 +114,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
Deferred.doneUnsafe(execution.done, Exit.succeed(exit))
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
@@ -116,11 +122,10 @@ 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.ignoreCause, Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done).pipe(Effect.flatten)
|
||||
}
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
|
||||
})
|
||||
|
||||
const wake = (key: Key, scope: Promotable = "input") =>
|
||||
@@ -162,7 +167,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -165,7 +165,9 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
// 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>()
|
||||
/**
|
||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
||||
@@ -332,10 +334,7 @@ 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* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
if (promoted > 0) yield* startTitle(sessionID)
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
const loaded = yield* context.load(selected)
|
||||
@@ -716,6 +715,22 @@ 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}`))
|
||||
|
||||
@@ -30,6 +30,9 @@ export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "shell"
|
||||
|
||||
type Info = Shell.Info
|
||||
type CreateInput = Shell.CreateInput & {
|
||||
shell?: string
|
||||
}
|
||||
|
||||
type Active = {
|
||||
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
|
||||
@@ -52,9 +55,8 @@ type Active = {
|
||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
@@ -185,8 +187,6 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
@@ -218,7 +218,7 @@ const layer = () =>
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const sessionID = input.metadata?.sessionID
|
||||
@@ -230,7 +230,7 @@ const layer = () =>
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* shell.preferred(),
|
||||
shell: input.shell ?? (yield* shell.resolve({ preference: "configured" })),
|
||||
env: {
|
||||
...(sessionEnvironment ?? process.env),
|
||||
TERM: "xterm-256color",
|
||||
@@ -383,7 +383,7 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -41,8 +41,12 @@ export type Draft = {
|
||||
configure: (shell: string) => void
|
||||
}
|
||||
|
||||
export type ResolveInput = {
|
||||
preference: "configured" | "compatible"
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly preferred: () => Effect.Effect<string>
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
|
||||
@@ -70,7 +74,7 @@ function meta(file: string) {
|
||||
return META[name(file)]
|
||||
}
|
||||
|
||||
function ok(file: string) {
|
||||
function compatible(file: string) {
|
||||
return meta(file)?.deny !== true
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ function rooted(file: string) {
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string, options?: Options, bin?: string) {
|
||||
function executable(file: string, options?: Options, bin?: string) {
|
||||
const shell = full(file, options, bin)
|
||||
if (rooted(shell)) {
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
@@ -108,9 +112,9 @@ async function unix() {
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file, options, bin)
|
||||
function select(file: string | undefined, options?: Options, opts?: { compatible?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.compatible || compatible(file))) {
|
||||
const shell = executable(file, options, bin)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win(options, bin)[0]
|
||||
@@ -151,8 +155,8 @@ function info(file: string, options?: Options, bin?: string): Item {
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n, options, bin) ? n : item,
|
||||
acceptable: ok(item),
|
||||
name: executable(n, options, bin) ? n : item,
|
||||
acceptable: compatible(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,38 +167,28 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
let defaultPreferred: { bin?: string; value: string } | undefined
|
||||
let defaultAcceptable: { bin?: string; value: string } | undefined
|
||||
let defaultConfigured: { bin?: string; value: string } | undefined
|
||||
let defaultCompatible: { bin?: string; value: string } | undefined
|
||||
|
||||
export function preferred(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, undefined, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
|
||||
const cached = defaultPreferred
|
||||
export function resolve(input: ResolveInput, configShell?: string, options?: Options, bin?: string) {
|
||||
const filter = input.preference === "compatible" ? { compatible: true } : undefined
|
||||
if (configShell) return select(configShell, options, filter, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, filter, bin)
|
||||
const cached = input.preference === "compatible" ? defaultCompatible : defaultConfigured
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
|
||||
defaultPreferred = { bin, value }
|
||||
const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
|
||||
if (input.preference === "compatible") defaultCompatible = { bin, value }
|
||||
if (input.preference === "configured") defaultConfigured = { bin, value }
|
||||
return value
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
|
||||
export function acceptable(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true }, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
|
||||
const cached = defaultAcceptable
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
|
||||
defaultAcceptable = { bin, value }
|
||||
return value
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
resolve.reset = () => {
|
||||
defaultConfigured = undefined
|
||||
defaultCompatible = undefined
|
||||
}
|
||||
|
||||
export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
return shells.filter((shell) => executable(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
}
|
||||
|
||||
const layer = (options?: Options) =>
|
||||
@@ -214,7 +208,7 @@ const layer = (options?: Options) =>
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
|
||||
resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -90,7 +90,6 @@ 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,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { Shell } from "../../shell.js"
|
||||
import { ShellParse } from "../../shell/parse.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const name = "shell"
|
||||
@@ -109,6 +110,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -185,6 +187,7 @@ export const Plugin = {
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
shell: yield* shellSelect.resolve({ preference: "compatible" }),
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
@@ -340,7 +343,8 @@ export const Plugin = {
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(yield* shell.name())
|
||||
const selected = yield* shellSelect.resolve({ preference: "compatible" })
|
||||
tool.description = description(ShellSelect.name(selected))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -24,12 +24,12 @@ describe("ConfigShellPlugin.Plugin", () => {
|
||||
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
|
||||
expect(yield* shell.preferred()).toBe(configured)
|
||||
expect(yield* shell.resolve({ preference: "configured" })).toBe(configured)
|
||||
|
||||
yield* config.setEntries([])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if ((yield* shell.preferred()) !== configured) return
|
||||
if ((yield* shell.resolve({ preference: "configured" })) !== configured) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
|
||||
|
||||
@@ -455,7 +455,7 @@ describe("Plugin", () => {
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: { input: unknown; inputSchema: unknown }
|
||||
before?: 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 = { input: event.input, inputSchema: event.inputSchema }
|
||||
seen.before = event.input
|
||||
event.input = { text: "before-mutated" }
|
||||
}),
|
||||
)
|
||||
@@ -526,15 +526,7 @@ describe("Plugin", () => {
|
||||
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
|
||||
expect(seen.before).toEqual({
|
||||
input: { text: "original" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(seen.before).toEqual({ text: "original" })
|
||||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
input: { text: "before-mutated" },
|
||||
|
||||
@@ -309,20 +309,14 @@ describe("SessionRunCoordinator", () => {
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
const resumed = 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 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()
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
expect(reasons).toEqual(["user"])
|
||||
|
||||
@@ -897,38 +897,30 @@ 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
|
||||
yield* prepareTitleGeneration
|
||||
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* admit(session, "First prompt")
|
||||
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
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 runner = yield* SessionRunner.Service
|
||||
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Fiber.join(renamed)
|
||||
@@ -938,46 +930,19 @@ 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
|
||||
yield* prepareTitleGeneration
|
||||
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* admit(session, "First prompt")
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
|
||||
@@ -996,7 +961,13 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* Effect.yieldNow
|
||||
expect((yield* session.get(sessionID)).title).toBeUndefined()
|
||||
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
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 }),
|
||||
)
|
||||
yield* admit(session, "Third prompt")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Generated title", "text-title"),
|
||||
|
||||
@@ -8,15 +8,13 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise<voi
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
ShellSelect.acceptable.reset()
|
||||
ShellSelect.preferred.reset()
|
||||
ShellSelect.resolve.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
ShellSelect.acceptable.reset()
|
||||
ShellSelect.preferred.reset()
|
||||
ShellSelect.resolve.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +34,16 @@ describe("shell", () => {
|
||||
|
||||
test("falls back when configured shell cannot be resolved", async () => {
|
||||
await withShell(undefined, async () => {
|
||||
const preferred = ShellSelect.preferred()
|
||||
const acceptable = ShellSelect.acceptable()
|
||||
expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
|
||||
expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
|
||||
const configured = ShellSelect.resolve({ preference: "configured" })
|
||||
const compatible = ShellSelect.resolve({ preference: "compatible" })
|
||||
expect(ShellSelect.resolve({ preference: "configured" }, "opencode-missing-shell")).toBe(configured)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" }, "opencode-missing-shell")).toBe(compatible)
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for terminal-only acceptable shells", () => {
|
||||
expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
|
||||
expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
|
||||
test("falls back for terminal-only shells when compatibility is required", () => {
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ preference: "compatible" }, "fish"))).not.toBe("fish")
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ preference: "compatible" }, "nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
test("builds command args per shell family", () => {
|
||||
@@ -65,14 +63,14 @@ describe("shell", () => {
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu")
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ preference: "compatible" }))).not.toBe("nu")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell))
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(FSUtil.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,19 +78,19 @@ describe("shell", () => {
|
||||
const bash = ShellSelect.gitbash()
|
||||
if (!bash) return
|
||||
await withShell("/usr/bin/bash", async () => {
|
||||
expect(ShellSelect.acceptable()).toBe(bash)
|
||||
expect(ShellSelect.preferred()).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" })).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare bash to Git Bash before PATH", async () => {
|
||||
const bash = ShellSelect.gitbash()
|
||||
if (!bash) return
|
||||
expect(ShellSelect.acceptable("bash")).toBe(bash)
|
||||
expect(ShellSelect.preferred("bash")).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" }, "bash")).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "configured" }, "bash")).toBe(bash)
|
||||
await withShell("bash", async () => {
|
||||
expect(ShellSelect.acceptable()).toBe(bash)
|
||||
expect(ShellSelect.preferred()).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" })).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +98,7 @@ describe("shell", () => {
|
||||
const shell = which("pwsh") || which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(ShellSelect.preferred()).toBe(shell)
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(shell)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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", pattern: "^a", allOf: [{ pattern: "z$" }] },
|
||||
code: { type: "string", allOf: [{ pattern: "^a" }, { pattern: "z$" }] },
|
||||
},
|
||||
required: ["code"],
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
@@ -136,6 +137,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
ShellSelect.node,
|
||||
Tool.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -30,7 +30,6 @@ 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,7 +106,6 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
supportsSpanPropagation: false,
|
||||
supportsNotifications: true,
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
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,14 +1,12 @@
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { Context, Effect, Exit, 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") {}
|
||||
@@ -16,24 +14,22 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
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 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 connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") {
|
||||
const start = Effect.fn("BackgroundService.start")(function* () {
|
||||
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(() =>
|
||||
@@ -42,7 +38,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
? path.join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version,
|
||||
version: cli.version,
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
onStart: (reason, previousVersion) =>
|
||||
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
|
||||
@@ -53,10 +49,10 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
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,
|
||||
version: cli.version,
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
if (isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
return {
|
||||
url: url.origin,
|
||||
username: service.auth.username,
|
||||
|
||||
@@ -22,7 +22,6 @@ export type UpdaterAPI = {
|
||||
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
|
||||
@@ -24,7 +24,6 @@ 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 { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
|
||||
import { initializationData } 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, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
|
||||
const [sidecar] = createResource(() => props.api.awaitInitialization())
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [locale] = createResource(() => preloadStoredLocale(platform))
|
||||
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
|
||||
@@ -97,8 +97,11 @@ function DesktopWindow(props: {
|
||||
displayName: language.t("desktop.server.local"),
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: sidecarHttp(data),
|
||||
reconnect: createSidecarResolver({ api: props.api, current: sidecar, update: setSidecar }),
|
||||
http: {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createSidecarResolver, initializationData } from "./initialization"
|
||||
import { initializationData } from "./initialization"
|
||||
|
||||
describe("desktop renderer initialization", () => {
|
||||
test("throws the original initialization error before rendering server providers", () => {
|
||||
@@ -45,48 +45,4 @@ 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,38 +1,8 @@
|
||||
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,7 +8,6 @@ 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),
|
||||
})
|
||||
@@ -57,7 +56,6 @@ 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.111"
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -14,7 +13,6 @@ 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,7 +5,6 @@ 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"> {
|
||||
@@ -31,7 +30,6 @@ 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,9 +1,7 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpResource,
|
||||
@@ -93,8 +91,6 @@ 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,9 +1282,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled() ? (event) => Selection.copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
|
||||
}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(-1, input.plainText)
|
||||
const item = history.move(props.sessionID, -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(1, input.plainText)
|
||||
const item = history.move(props.sessionID, 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(entry)
|
||||
history.append(target, 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({
|
||||
history.append(props.sessionID, {
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
|
||||
@@ -26,6 +26,11 @@ 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
|
||||
@@ -36,12 +41,19 @@ export function parsePromptHistory(text: string) {
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return parsePromptInfo(JSON.parse(line))
|
||||
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,
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.filter((line): line is PromptInfo => line !== undefined)
|
||||
.filter((line): line is PromptHistoryEntry => line !== undefined)
|
||||
.slice(-MAX_HISTORY_ENTRIES)
|
||||
}
|
||||
|
||||
@@ -71,24 +83,28 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({ index: 0, history: [] as PromptInfo[] })
|
||||
const [store, setStore] = createStore({ history: [] as PromptHistoryEntry[] })
|
||||
const indices = new Map<string | undefined, number>()
|
||||
|
||||
return {
|
||||
move(direction: 1 | -1, input: string) {
|
||||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
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
|
||||
if (!current) return undefined
|
||||
if (current.text !== input && input.length) return
|
||||
const next = store.index + direction
|
||||
if (Math.abs(next) > store.history.length || next > 0) return
|
||||
setStore("index", next)
|
||||
const next = index + direction
|
||||
if (Math.abs(next) > items.length || next > 0) return
|
||||
indices.set(sessionID, next)
|
||||
if (next === 0) return emptyPrompt()
|
||||
return store.history.at(next)
|
||||
return items.at(next)?.prompt
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
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)
|
||||
return
|
||||
}
|
||||
let trimmed = false
|
||||
@@ -99,9 +115,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,7 +12,8 @@ import {
|
||||
import open from "open"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
|
||||
import { useData, type FormWithLocation } from "../../context/data"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
@@ -40,8 +41,22 @@ function truncate(label: string, max: number) {
|
||||
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const data = useData()
|
||||
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()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const themeMode = themes.mode
|
||||
@@ -243,9 +258,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
function reply(answer: FormAnswer) {
|
||||
void data.session.form
|
||||
.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer }, props.form.location)
|
||||
.catch(showError)
|
||||
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)))
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
@@ -435,13 +457,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
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))
|
||||
if (props.onCancel) {
|
||||
void props.onCancel()
|
||||
return
|
||||
}
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
|
||||
@@ -211,6 +210,17 @@ 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}
|
||||
@@ -221,11 +231,11 @@ export function DialogProvider(props: ParentProps) {
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!copy(renderer, toast, clipboard)) return
|
||||
if (!copySelection()) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={copyOnSelectEnabled() ? copySelection : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
||||
|
||||
@@ -23,16 +23,6 @@ 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
|
||||
@@ -49,8 +39,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
// Keep the highlight. clearSelection() also resets OpenTUI's click
|
||||
// counter, so clearing here would turn a triple-click into a new single-click.
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { onMount, Show } from "solid-js"
|
||||
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
|
||||
import type { FormWithLocation } from "../../../src/context/data"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
@@ -13,7 +12,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, json } from "../../fixture/tui-client"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
async function mountForm(
|
||||
root: string,
|
||||
@@ -21,17 +20,23 @@ 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",
|
||||
@@ -46,44 +51,8 @@ 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
|
||||
@@ -106,11 +75,11 @@ async function mountForm(
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>{response ? <CurrentForm /> : <FormPrompt form={form} />}</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
@@ -121,63 +90,7 @@ 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, 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()
|
||||
}
|
||||
})
|
||||
return { app, copied, replies }
|
||||
}
|
||||
|
||||
test("requires explicit acknowledgement before submitting an external field", async () => {
|
||||
|
||||
@@ -11,27 +11,28 @@ 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({ text: "previous", files: [], agents: [], pasted: [] })
|
||||
setup.history.append("session-a", { text: "previous", files: [], agents: [], pasted: [] })
|
||||
|
||||
expect(setup.history.move(1, "")).toBeUndefined()
|
||||
expect(setup.history.move(-1, "")?.text).toBe("previous")
|
||||
expect(setup.history.move(1, "previous")?.text).toBe("")
|
||||
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("")
|
||||
} finally {
|
||||
setup.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("shares prompt history across sessions and the home composer", async () => {
|
||||
test("keeps independent prompt history and cursors for each session", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const setup = await renderHistory(tmp.path)
|
||||
try {
|
||||
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: [] })
|
||||
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: [] })
|
||||
|
||||
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")
|
||||
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("")
|
||||
} finally {
|
||||
setup.app.renderer.destroy()
|
||||
}
|
||||
@@ -44,7 +45,8 @@ test("keeps legacy unscoped history on the home composer", async () => {
|
||||
try {
|
||||
expect((await waitForHistory(setup.history))?.text).toBe("legacy")
|
||||
|
||||
expect(setup.history.move(1, "legacy")?.text).toBe("")
|
||||
expect(setup.history.move("session-a", -1, "")).toBeUndefined()
|
||||
expect(setup.history.move(undefined, 1, "legacy")?.text).toBe("")
|
||||
} finally {
|
||||
setup.app.renderer.destroy()
|
||||
}
|
||||
@@ -74,7 +76,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(-1, "")
|
||||
const item = history.move(undefined, -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([
|
||||
entry("one"),
|
||||
entry("two"),
|
||||
{ sessionID: undefined, prompt: entry("one") },
|
||||
{ sessionID: undefined, prompt: entry("two") },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("prompt history", () => {
|
||||
).join("\n")
|
||||
const result = parsePromptHistory(input)
|
||||
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
|
||||
expect(result[0]?.text).toBe("5")
|
||||
expect(result[0]?.prompt.text).toBe("5")
|
||||
})
|
||||
|
||||
test("dedupes only identical consecutive entries", () => {
|
||||
@@ -56,6 +56,11 @@ 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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
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