Compare commits

..
Author SHA1 Message Date
Aiden Cline f484949568 fix(core): restore shell tool fallback 2026-08-21 00:49:59 -05:00
1182 changed files with 35254 additions and 54697 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
-6
View File
@@ -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.
+1
View File
@@ -27,6 +27,7 @@ jobs:
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
+6 -37
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.0
bun-version: canary # Bun 1.4 until its stable release is published
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.0
BUN_COMPILE_RELEASE: canary
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
@@ -195,33 +195,9 @@ jobs:
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-app-archive:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build app archive
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-app-archive
path: packages/cli/.cache/app-archive.bin
if-no-files-found: error
build-node-cli:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode'
needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
strategy:
fail-fast: false
matrix:
@@ -234,7 +210,6 @@ jobs:
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
bun_install_flags: --cpu=*
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
@@ -246,19 +221,14 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
install-flags: ${{ matrix.settings.bun_install_flags }}
install-flags: --os=* --cpu=*
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-node-app-archive
path: packages/cli/.cache
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -573,7 +543,6 @@ jobs:
- version
- build-cli
- sign-cli-macos
- build-node-app-archive
- build-node-cli
- sign-cli-windows
- build-electron
-19
View File
@@ -50,14 +50,6 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
- name: Check Effect simplifications
if: runner.os == 'Linux'
run: bun run lint:effect-simplifications
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
@@ -84,17 +76,6 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
working-directory: packages/sdk
run: bun run verify:package
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -1,3 +1,19 @@
{
"$schema": "https://opencode.ai/tui.json"
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
+2462 -930
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
}
}
+18 -17
View File
@@ -18,41 +18,40 @@
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*"
"packages/stats/*",
"packages/slack"
],
"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",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@tanstack/solid-virtual": "3.13.37",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
@@ -71,9 +70,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -129,6 +128,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
@@ -166,6 +166,7 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -173,7 +174,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
}
}
+1 -1
View File
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
- Input failed the `parameters` Schema.
- The handler returned a `ToolFailure`.
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `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.
+1 -1
View File
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
if (response.status >= 200 && response.status < 300) return undefined
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
})
+44 -371
View File
@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
@@ -42,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)
@@ -55,7 +53,7 @@ export type ThinkingInput =
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| ({ readonly type: "enabled" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
@@ -64,19 +62,6 @@ export interface OptionsInput {
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
}
export type ProviderOptionsInput = OptionsInput
@@ -96,61 +81,25 @@ const AnthropicTextBlock = Schema.Struct({
})
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
// SDK: Base64ImageSource:201 {type:"base64", media_type:"image/jpeg"|... , data}, URLImageSource:3817 {type:"url", url}, FileImageSource:2350 {type:"file", file_id}
// SDK: ImageBlockParam:2356 {source: Base64|URL|File, cache_control, transformations:2381 {oversized_image?}}
const AnthropicBase64ImageSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
})
const AnthropicURLImageSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileImageSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicImageSource = Schema.Union([
AnthropicBase64ImageSource,
AnthropicURLImageSource,
AnthropicFileImageSource,
])
const AnthropicImageTransformations = Schema.Struct({
oversized_image: Schema.optional(Schema.Literals(["downsize", "error"])),
})
const AnthropicImageBlock = Schema.Struct({
type: Schema.tag("image"),
source: AnthropicImageSource,
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
transformations: Schema.optional(AnthropicImageTransformations),
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
// SDK: Base64PDFSource:209 {type:"base64", media_type:"application/pdf", data}, PlainTextSource:2716 {type:"text", media_type:"text/plain", data},
// SDK: URLPDFSource:3823 {type:"url", url}, FileDocumentSource:2344 {type:"file", file_id}, ContentBlockSource:2266 {type:"content", content}
// SDK: DocumentBlockParam:2297 {source: 5-way union, cache_control, citations, context, title}
const AnthropicBase64PDFSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
})
const AnthropicPlainTextSource = Schema.Struct({
type: Schema.tag("text"),
media_type: Schema.Literal("text/plain"),
data: Schema.String,
})
const AnthropicURLPDFSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileDocumentSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicDocumentSource = Schema.Union([
AnthropicBase64PDFSource,
AnthropicPlainTextSource,
AnthropicURLPDFSource,
AnthropicFileDocumentSource,
])
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: AnthropicDocumentSource,
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
title: Schema.optional(Schema.String),
context: Schema.optional(Schema.String),
citations: Schema.optional(Schema.Struct({ enabled: Schema.Boolean })),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
@@ -255,18 +204,14 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({
type: Schema.Literals(["auto", "any", "none"]),
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
@@ -277,28 +222,10 @@ const AnthropicThinking = Schema.Union([
}),
])
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
const AnthropicJsonOutputFormat = Schema.Struct({
type: Schema.Literal("json_schema"),
schema: JsonObject,
})
const AnthropicOutputConfig = Schema.Struct({
effort: Schema.optional(Schema.String),
format: Schema.optional(Schema.NullOr(AnthropicJsonOutputFormat)),
})
// SDK Metadata:2649 {user_id?: string|null}
const AnthropicMetadata = Schema.Struct({ user_id: optionalNull(Schema.String) })
// SDK MessageCreateParamsContainer:2596 ContainerParams|string; ContainerParams:2172 {id?, skills?}
const AnthropicContainer = Schema.Union([
Schema.String,
Schema.Struct({
id: optionalNull(Schema.String),
skills: optionalNull(Schema.Array(JsonObject)),
}),
])
const AnthropicBodyFields = {
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
@@ -313,12 +240,6 @@ const AnthropicBodyFields = {
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
output_config: Schema.optional(AnthropicOutputConfig),
// SDK top-level passthrough: cache_control:4638, container:4643, inference_geo:4649, metadata:4654, service_tier:4670
cache_control: Schema.optional(AnthropicCacheControl),
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -447,40 +368,22 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({
type: "auto" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
auto: () => ({ type: "auto" as const }),
none: () => ({ type: "none" as const }),
required: () => ({
type: "any" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
tool: (name) => ({
type: "tool" as const,
name,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
type: "tool_use",
id: scrubToolCallID(part.id),
id: part.id,
name: part.name,
input: part.input,
})
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
type: "server_tool_use",
id: scrubToolCallID(part.id),
id: part.id,
name: part.name,
input: part.input,
})
@@ -502,155 +405,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
})
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = metadata.anthropic
if (ProviderShared.isRecord(anthropic)) {
if (typeof anthropic.file_id === "string") return anthropic.file_id
if (typeof anthropic.fileId === "string") return anthropic.fileId
}
if (typeof metadata.file_id === "string") return metadata.file_id
if (typeof metadata.fileId === "string") return metadata.fileId
return undefined
}
const transformationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicImageBlock["transformations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
const raw = anthropic?.transformations ?? metadata.transformations
if (ProviderShared.isRecord(raw)) {
const value = raw.oversized_image
if (value === "downsize" || value === "error") return { oversized_image: value }
}
if (anthropic && (anthropic.oversized_image === "downsize" || anthropic.oversized_image === "error"))
return { oversized_image: anthropic.oversized_image }
return undefined
}
const documentTitleFromPart = (part: MediaPart): string | undefined => {
if (ProviderShared.isRecord(part.metadata)) {
const anthropic = part.metadata.anthropic
if (ProviderShared.isRecord(anthropic) && typeof anthropic.title === "string") return anthropic.title
if (typeof part.metadata.title === "string") return part.metadata.title
}
if (typeof part.filename === "string" && part.filename.length > 0) return part.filename
return undefined
}
const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
if (anthropic && typeof anthropic.context === "string") return anthropic.context
if (typeof metadata.context === "string") return metadata.context
return undefined
}
const citationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicDocumentBlock["citations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const raw = ProviderShared.isRecord(metadata.anthropic)
? (metadata.anthropic.citations ?? metadata.citations)
: metadata.citations
if (ProviderShared.isRecord(raw) && typeof raw.enabled === "boolean") return { enabled: raw.enabled }
return undefined
}
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
part: MediaPart,
breakpoints?: Cache.Breakpoints,
) {
const mime = part.mediaType.toLowerCase()
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
const fileId = fileIdFromMetadata(part.metadata)
// SDK file sources: FileImageSource:2350 / FileDocumentSource:2344 {type:"file", file_id}
if (fileId) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
return {
type: "document" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const rawString = typeof part.data === "string" ? part.data.trim() : undefined
// SDK URL sources: URLImageSource:3817 / URLPDFSource:3823 {type:"url", url}
if (rawString && isHttpUrl(rawString) && !rawString.startsWith("data:")) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
if (mime === "application/pdf")
return {
type: "document" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
// SDK PlainTextSource:2716 {type:"text", media_type:"text/plain", data}
if (mime === "text/plain") {
const textData =
typeof part.data !== "string"
? Buffer.from(part.data).toString("utf8")
: part.data.startsWith("data:")
? (() => {
const comma = part.data.indexOf(",")
const payload = comma >= 0 ? part.data.slice(comma + 1) : part.data
return part.data.includes(";base64")
? Buffer.from(payload, "base64").toString("utf8")
: decodeURIComponent(payload)
})()
: part.data
return {
type: "document" as const,
source: { type: "text" as const, media_type: "text/plain" as const, data: textData },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
if (media.mime === "application/pdf")
return {
@@ -660,14 +418,6 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
media_type: "application/pdf" as const,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
if (!media.mime.startsWith("image/"))
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
@@ -678,10 +428,6 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
media_type: media.mime,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
})
@@ -790,7 +536,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerMedia(part, breakpoints))
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -841,7 +587,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
content.push({
type: "tool_result",
tool_use_id: scrubToolCallID(part.id),
tool_use_id: part.id,
content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
@@ -857,74 +603,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) &&
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
const input = request.providerOptions
return {
thinking: yield* resolveThinking(input?.thinking),
effort: outputConfigEffort,
output_config,
service_tier,
metadata,
container,
inference_geo,
cache_control,
effort: typeof input?.effort === "string" ? input.effort : undefined,
}
})
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
if (input.type === "adaptive") {
const display =
input.display === "summarized"
? ("summarized" as const)
: input.display === "omitted"
? ("omitted" as const)
: undefined
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
const budget =
@@ -935,7 +631,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
return { type: "enabled" as const, budget_tokens: budget }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
@@ -985,13 +681,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier,
output_config: options.effort === undefined ? undefined : { effort: options.effort },
}
})
@@ -1102,8 +792,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1111,7 +800,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
...state,
lifecycle,
tools: ToolStream.start(state.tools, event.index, {
id: block.id,
id: block.id ?? String(event.index),
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
@@ -1123,7 +812,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
[
...events,
LLMEvent.toolInputStart({
id: block.id,
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
@@ -1313,28 +1002,15 @@ 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))
if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event))
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
@@ -1373,10 +1049,7 @@ export const route = Route.make({
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
{ baseURL: DEFAULT_BASE_URL },
),
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
+68 -120
View File
@@ -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"
@@ -37,17 +37,6 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
const omitsFunctionCallIds = (modelID: string) => {
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
return match !== null && Number(match[1]) < 3
}
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -82,15 +71,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 +87,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 +111,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,45 +175,44 @@ 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>
interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
readonly seenCallIds?: ReadonlySet<string>
}
// =============================================================================
@@ -287,31 +270,29 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
: undefined
}
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
? google.functionCallId
: undefined
}
const lowerToolCall = (part: ToolCallPart) => ({
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
})
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const omitCallIds = omitsFunctionCallIds(request.model.id)
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
const flushMedia = () => {
if (!pendingMedia) return
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
pendingMedia = undefined
}
for (const message of request.messages) {
if (message.role !== "tool") flushMedia()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
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
}
@@ -335,7 +316,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: part.text })
continue
}
if (part.type === "reasoning") {
@@ -343,7 +324,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part, omitCallIds)
const lowered = lowerToolCall(part)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
@@ -368,7 +349,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (part.result.type !== "content") {
parts.push({
functionResponse: {
...(omitCallIds ? {} : { id: part.id }),
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
@@ -386,28 +367,21 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const value = ProviderShared.normalizeToolFile(item)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
parts.push({
functionResponse: {
...(omitCallIds ? {} : { id: part.id }),
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
content: text.join("\n"),
},
parts: legacyToolMedia || media.length === 0 ? undefined : media,
parts: media.length > 0 ? media : undefined,
},
})
}
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
else contents.push({ role: "user", parts })
contents.push({ role: "user", parts })
}
flushMedia()
return contents
})
@@ -493,25 +467,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,24 +516,19 @@ 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 []
const events: LLMEvent[] = []
let lifecycle = state.lifecycle
if (state.reasoningSignature !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -593,17 +558,12 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
// 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 ?? []) {
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.
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
else if (signature !== undefined && "text" in part) textSignature = signature
for (const part of candidate.content.parts) {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
@@ -611,7 +571,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
continue
}
@@ -621,27 +581,17 @@ const step = (state: ParserState, event: GeminiEvent) => {
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
// 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 duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
@@ -654,8 +604,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
}),
)
hasToolCalls = true
@@ -667,9 +616,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
seenCallIds,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
events,
@@ -691,7 +639,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
-1
View File
@@ -8,4 +8,3 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenAIResponses from "./openai-responses.js"
export * as OpenResponses from "./open-responses.js"
export * as OpenResponsesChannel from "./open-responses-channel.js"
export * as XAIResponses from "./xai-responses.js"
@@ -10,7 +10,6 @@ import {
} from "../route/transport/index.js"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
Schema.Record(Schema.String, Schema.Unknown),
@@ -23,9 +22,12 @@ export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}) => WebSocketChannelDriver
}
export interface Prepared {
@@ -112,8 +114,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
@@ -147,25 +147,18 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel =
input.webSocket && (options.enabled?.(parts.url) ?? true)
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: OpenResponsesContinuation.driver({
id: options.id,
name: options.name,
request: create.request,
message: create.message,
base,
}),
}
})
: undefined
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+97 -171
View File
@@ -5,7 +5,6 @@ import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
ProviderInternalReason,
Usage,
type FinishReason,
type JsonSchema,
@@ -43,12 +42,8 @@ const OpenResponsesInputImage = Schema.Struct({
const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
file_data: Schema.optional(Schema.String),
file_url: Schema.optional(Schema.String),
})
const OpenResponsesInputVideo = Schema.Struct({
type: Schema.tag("input_video"),
video_url: Schema.String,
file_data: Schema.String,
mime_type: Schema.optional(Schema.String),
})
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
@@ -59,14 +54,9 @@ const OpenResponsesOutputText = Schema.Struct({
text: Schema.String,
})
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -91,7 +81,6 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
OpenResponsesInputText,
OpenResponsesInputImage,
OpenResponsesInputFile,
OpenResponsesInputVideo,
])
const OpenResponsesFunctionCallOutput = Schema.Union([
@@ -248,7 +237,6 @@ const OpenResponsesErrorPayload = Schema.Struct({
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
@@ -313,8 +301,19 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension {
readonly id: string
@@ -324,10 +323,7 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -340,6 +336,7 @@ export interface ParserState {
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
@@ -353,10 +350,6 @@ interface ReasoningStreamItem {
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -373,7 +366,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: ToolSchemaProjection.responses(inputSchema),
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
@@ -387,46 +380,28 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
// Servers validate item ids on replayed history, and a malformed or oversized
// id can fail an otherwise valid request. Only server-issued tokens are worth
// resending; anything else is treated as absent so the item is resent without
// an id (or skipped, for items that cannot be expressed without one).
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) &&
typeof metadata.itemId === "string" &&
ITEM_ID_PATTERN.test(metadata.itemId)
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesInputItem => {
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
if (!ProviderShared.isRecord(metadata) || !id) return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
@@ -439,29 +414,26 @@ const lowerReasoning = (
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
return itemID(part.providerMetadata, providerMetadataKey)
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
: undefined
if (!media.mime.startsWith("image/")) {
if (target === "tool-result" && media.mime.startsWith("video/"))
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
...(url ? { file_url: url } : { file_data: media.dataUrl }),
file_data: media.dataUrl,
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerUserContent = Effect.fnUntraced(function* (
@@ -470,17 +442,10 @@ const lowerUserContent = Effect.fnUntraced(function* (
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
if (part.type === "media") return yield* lowerMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* (
@@ -493,20 +458,6 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
"tool-result",
)
})
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
)
})
@@ -558,9 +509,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const rawID = itemID(part.providerMetadata, providerMetadataKey)
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
@@ -584,7 +534,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
@@ -605,30 +555,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey, extension))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (reference) hostedToolReferences.add(reference)
if (itemID) hostedToolReferences.add(itemID)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -665,7 +607,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -675,7 +616,7 @@ const lowerOptions = (request: LLMRequest) => {
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -791,7 +732,7 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
@@ -809,33 +750,20 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
},
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
}
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -854,17 +782,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id) {
const phase = messagePhase(item.phase)
if (item?.type === "message" && item.id)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
},
NO_EVENTS,
]
}
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -873,11 +802,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
},
},
events,
@@ -907,9 +832,27 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
@@ -988,7 +931,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
@@ -1008,7 +951,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemPhase = messagePhase(item.phase)
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
@@ -1116,45 +1059,32 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: Event, fallback: string): string => {
const nested = event.error ?? event.response?.error ?? undefined
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
return message || code || fallback
}
export const providerFailure = (id: string, event: Event, fallback: string) => {
const nested = event.error ?? event.response?.error ?? undefined
const code = event.code || nested?.code || undefined
// Keep the full raw payload on the error even when the message is a summary.
const body = JSON.stringify(nested ?? event) ?? ""
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
const status =
typeof event.status === "number"
? event.status
: 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 }),
})
}
@@ -1170,27 +1100,20 @@ 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 (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
return Effect.succeed(onReasoningDone(state, event))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
@@ -1205,10 +1128,7 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta")
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
@@ -1239,11 +1159,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
if (value === "commentary" || value === "final_answer") return value
return extension.messagePhase?.(value)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
+10 -43
View File
@@ -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])),
@@ -269,7 +261,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -517,23 +509,11 @@ 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)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
}
@@ -552,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,
@@ -604,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))
@@ -721,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
+1 -1
View File
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.undefined
return Effect.succeed(undefined)
})
const multipartMask =
mask === undefined
@@ -4,7 +4,7 @@ import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const PROTOCOL = "openai-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
@@ -161,4 +161,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export const OpenAIResponsesChannel = { driver } as const
+98 -40
View File
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -39,8 +40,20 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -51,27 +64,18 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
messagePhase: (value: unknown) => (value === null ? null : undefined),
lowerMedia: ({ part, media, request }) => {
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
return {
type: "input_file",
filename: part.filename ?? "document.pdf",
file_data: media.base64,
mime_type: media.mime,
}
},
} satisfies OpenResponses.Extension
@@ -124,7 +128,46 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
} satisfies OpenAIResponsesBody
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
type HostedToolData = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
readonly results?: unknown
readonly code?: string
readonly container_id?: string
readonly outputs?: unknown
readonly server_label?: string
readonly output?: unknown
readonly result?: string
readonly output_format?: "png" | "jpeg" | "webp"
readonly error?: unknown
}
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}) },
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 Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
type HostedToolType = keyof typeof HOSTED_TOOLS
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
@@ -145,29 +188,44 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
} as const satisfies ResponsesHostedTools.Definitions
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
state: OpenResponses.ParserState,
item: HostedToolItem,
) {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
return OpenResponses.step(state, event)
}
@@ -189,12 +247,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
export const transport = channelTransport({
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({
-11
View File
@@ -24,17 +24,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
url: string,
module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.undefined
if (!url.startsWith("data:")) return Effect.succeed(undefined)
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
+2 -8
View File
@@ -21,15 +21,9 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
): State => {
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
events.push(LLMEvent.textDelta({ id, text }))
return started
}
@@ -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
@@ -1,70 +0,0 @@
import { Effect } from "effect"
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
readonly results?: unknown
readonly code?: string
readonly container_id?: string
readonly outputs?: unknown
readonly server_label?: string
readonly output?: unknown
readonly result?: string
readonly output_format?: "png" | "jpeg" | "webp"
readonly error?: unknown
}
export interface Definition {
readonly name: string
readonly input: (item: Item) => unknown
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
}
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const onDone: (
state: OpenResponses.ParserState,
item: Item,
tools: Definitions,
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
state,
item,
tools,
) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: tool.result
? yield* tool.result(item)
: item.error !== undefined && item.error !== null
? { type: "error", value: item.error }
: { type: "json", value: item },
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
+32 -1
View File
@@ -2,6 +2,20 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
import { isRecord } from "../../utils/record.js"
import { GeminiToolSchema } from "./gemini-tool-schema.js"
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode)
if (projected.length === 0) return {}
@@ -31,7 +45,24 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
return isRecord(projected) ? projected : {}
}
const openAI = (schema: JsonSchema): JsonSchema => schema
const openAI = (schema: JsonSchema): JsonSchema => {
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
@@ -1,47 +0,0 @@
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const extension = {
id: ADAPTER,
name: NAME,
} satisfies OpenResponses.Extension
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
image_generation_call: { name: "image_generation", input: () => ({}) },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
} as const satisfies ResponsesHostedTools.Definitions
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: OpenResponses.protocol.body,
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
})
export * as XAIResponses from "./xai-responses.js"
+2 -9
View File
@@ -74,15 +74,11 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
readonly status?: number | undefined
readonly code?: string | undefined
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
// that the summary message does not carry. Not shown to users.
readonly rawBody?: string | undefined
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http?: HttpContext | undefined
@@ -92,13 +88,11 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? input.rawBody ?? ""
const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase())
// Scan the raw payload too so signals missing from the summary message
// (e.g. overflow phrases nested in a JSON error body) still classify.
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const text = body || input.message
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
@@ -133,7 +127,6 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
-26
View File
@@ -1,4 +1,3 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
@@ -11,7 +10,6 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization")
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
@@ -42,30 +40,6 @@ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
transport: OpenAIResponses.channelTransport({
id: "azure-openai-responses",
name: "Azure OpenAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
enabled: (value) => {
const url = new URL(value)
return (
url.protocol === "https:" &&
url.hostname.endsWith(".openai.azure.com") &&
url.pathname.endsWith("/openai/v1/responses") &&
url.searchParams.get("api-version") === "v1"
)
},
url: (value) => {
const url = new URL(value)
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
const chatRoute = OpenAIChat.route.with({
@@ -13,7 +13,6 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
const HEADER_VERSION = "2023-06-01" as const
export const id = ProviderID.make("google-vertex")
@@ -58,7 +57,6 @@ const route = Route.make({
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
export const routes = [route]
+1 -11
View File
@@ -38,23 +38,13 @@ export type Settings = ProviderPackage.Settings &
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const body = yield* Gemini.protocol.body.from(request)
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
...content,
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
}),
}))
const value = request.providerOptions?.labels
const labels = ProviderShared.isRecord(value)
? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
: undefined
return { ...body, contents, labels }
return { ...body, labels }
})
const protocol = {
+2 -6
View File
@@ -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
+2 -3
View File
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
import { isRecord } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,12 +115,11 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody
}),
),
+3 -10
View File
@@ -5,8 +5,7 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
import { XAIResponses } from "../protocols/xai-responses.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { XAIImages } from "../protocols/xai-images.js"
import type { OpenAIOptionsInput } from "./openai-options.js"
import type { ProviderPackage } from "../provider-package.js"
@@ -29,19 +28,13 @@ export interface Settings extends ProviderPackage.Settings {
export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenResponsesChannel.transport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { store: false } },
})
-3
View File
@@ -153,9 +153,6 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
module: Schema.String,
method: Schema.String,
reason: AIErrorReason,
// Raw provider payload as a string, so classified failures never lose the
// original error detail even when the pretty message is a summary.
body: Schema.optional(Schema.String),
}) {
override readonly cause = this.reason
-2
View File
@@ -51,7 +51,6 @@ export const MediaPart = Schema.Struct({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -256,7 +255,6 @@ export namespace ToolDefinition {
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
disableParallelToolUse: Schema.optional(Schema.Boolean),
}) {}
export namespace ToolChoice {
@@ -1,31 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"name": "azure/chat-streams-text",
"recordedAt": "2026-08-23T17:21:53.198Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
}
}
]
}
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
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/calls-a-tool",
"recordedAt": "2026-08-23T17:21:51.036Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
}
}
]
}
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:51.853Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
}
}
]
}
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/streams-text",
"recordedAt": "2026-08-23T17:21:50.112Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
}
}
]
}
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
-25
View File
@@ -82,14 +82,6 @@ describe("provider error classification", () => {
])
})
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
@@ -106,20 +98,3 @@ describe("provider error classification", () => {
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
})
})
describe("provider error rawBody classification", () => {
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
})
expect(reason._tag).toBe("InvalidRequest")
expect(reason).toMatchObject({ classification: "context-overflow" })
})
test("extracts nested codes from the raw payload", () => {
expect(
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
})
})
@@ -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" } })
@@ -327,29 +327,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("scrubs outbound tool call IDs without truncating them", () =>
Effect.gen(function* () {
const id = `functions.lookup:1|${"x".repeat(64)}`
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "done" }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
])
expect(scrubbed.length).toBeGreaterThan(64)
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1416,14 +1393,14 @@ describe("Anthropic Messages route", () => {
Message.assistant([
{
type: "tool-call",
id: "srvtoolu.abc",
id: "srvtoolu_abc",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
},
{
type: "tool-result",
id: "srvtoolu.abc",
id: "srvtoolu_abc",
name: "web_search",
result: { type: "json", value: [{ url: "https://example.com" }] },
providerExecuted: true,
@@ -1,93 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
import { Azure } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
const chatModel = Azure.configure({
resourceName,
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
}).chat("gpt-5.6-luna")
const responsesModel = Azure.configure({
resourceName,
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
}).responses("gpt-5.6-luna")
const lookupWeather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
})
const recorded = recordedTests({
prefix: "azure",
provider: "azure",
requires: ["AZURE_OPENAI_API_KEY"],
})
describe("Azure OpenAI recorded", () => {
recorded.effect("chat streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
)
expect(response.text.toLowerCase()).toContain("hello")
}),
)
recorded.effect("responses streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
)
expect(response.text.toLowerCase()).toContain("bonjour")
}),
)
recorded.effect("responses calls a tool", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: responsesModel,
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
tools: [lookupWeather],
}),
)
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
expect(call).toBeDefined()
expect(call?.input).toMatchObject({ city: "Paris" })
}),
)
recorded.effect("responses continues after a tool result", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: responsesModel,
messages: [
Message.user("What is the weather in Paris?"),
Message.assistant([
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
]),
Message.tool({
id: "call_paris_1",
name: "lookup_weather",
result: "18C, light rain",
resultType: "text",
}),
],
tools: [lookupWeather],
}),
)
expect(response.text.length).toBeGreaterThan(0)
}),
)
})
+43 -519
View File
@@ -156,13 +156,14 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
@@ -180,149 +181,6 @@ describe("Gemini route", () => {
}),
)
it.effect("merges parallel tool results into one function-response turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "time" } } },
],
},
{
role: "user",
parts: [
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: "noon" },
},
},
],
},
])
}),
)
it.effect("lowers function call ids for gemini 3 models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_1",
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
}),
)
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
Effect.gen(function* () {
const messages = [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
]
const legacy = yield* compileRequest(LLM.request({ model, messages }))
const older = yield* compileRequest(
LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-1.5-flash" }),
messages,
}),
)
expect(legacy.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
},
])
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
}),
)
it.effect("includes function call ids for non-gemini model ids", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemma-3-27b-it" }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -423,18 +281,14 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
])
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
}),
@@ -467,164 +321,11 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
],
},
])
}),
)
it.effect("nests media inside function responses for gemini 3", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_image",
name: "read",
input: { path: "pixel.png" },
providerMetadata: { google: { thoughtSignature: "sig_1" } },
}),
]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_image",
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
},
},
],
},
])
}),
)
it.effect("flushes pending media before system update text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.system("Update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
{
role: "user",
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
{ text: "<system-update>\nUpdate.\n</system-update>" },
],
},
])
}),
)
it.effect("collects legacy tool media into one turn after merged responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.tool({
id: "call_2",
name: "shot",
result: {
type: "content",
value: [{ type: "text", text: "no image here" }],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
},
{
role: "user",
parts: [
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
],
},
])
}),
)
@@ -946,8 +647,8 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({
id: "provider_call",
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
id: "tool_0",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
@@ -955,22 +656,23 @@ describe("Gemini route", () => {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
ToolCallPart.make({
id: "provider_call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "provider_call",
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
@@ -1002,61 +704,6 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves thoughtSignature on visible text parts", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
expect(delta).toMatchObject({
id: "text-0",
text: "All done.",
providerMetadata: { google: { thoughtSignature: "text_sig" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
])
}),
)
it.effect("flushes a trailing empty signed text part at block close", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
const end = response.events.find((event) => event.type === "text-end")
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
expect(end).toMatchObject({
id: "text-0",
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
})
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1074,7 +721,7 @@ describe("Gemini route", () => {
role: "model",
parts: [
{
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
@@ -1084,7 +731,7 @@ describe("Gemini route", () => {
parts: [
{
functionResponse: {
id: "tool_0",
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
@@ -1120,15 +767,15 @@ describe("Gemini route", () => {
role: "model",
parts: [
{
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "parallel_signature",
},
{
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
thoughtSignature: undefined,
},
{
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
thoughtSignature: undefined,
},
],
@@ -1156,11 +803,11 @@ describe("Gemini route", () => {
role: "model",
parts: [
{
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
{
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
@@ -1198,17 +845,21 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
})
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "weather" },
})
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-call",
id: response.toolCalls[0].id,
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1251,8 +902,7 @@ describe("Gemini route", () => {
),
)
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
}),
)
@@ -1292,7 +942,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -1306,19 +956,16 @@ describe("Gemini route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
id: "call_0",
name: "lookup",
input: { query: "weather" },
})
expect(response.toolCalls[1]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "news" },
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
@@ -1326,62 +973,6 @@ describe("Gemini route", () => {
}),
)
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls[0]).toMatchObject({
id: "dup_call",
providerMetadata: undefined,
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
},
],
})
const req = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
}),
)
it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () {
const length = yield* LLMClient.generate(request).pipe(
@@ -1492,73 +1083,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(
@@ -1,77 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
import { GoogleVertex } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const model = GoogleVertex.configure({
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
}).model("gemini-3.5-flash")
const lookupWeather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
})
const recorded = recordedTests({
prefix: "google-vertex",
provider: "google-vertex",
protocol: "gemini",
requires: ["GOOGLE_VERTEX_API_KEY"],
})
describe("Google Vertex Gemini recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
)
expect(response.text.toLowerCase()).toContain("hello")
}),
)
recorded.effect("calls a tool", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
tools: [lookupWeather],
}),
)
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
expect(call).toBeDefined()
expect(call?.input).toMatchObject({ city: "Paris" })
}),
)
recorded.effect("continues after a tool result", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
messages: [
Message.user("What is the weather in Paris?"),
Message.assistant([
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
]),
Message.tool({
id: "call_paris_1",
name: "lookup_weather",
result: "18C, light rain",
resultType: "text",
}),
],
tools: [lookupWeather],
}),
)
expect(response.text.length).toBeGreaterThan(0)
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { LLM } from "../../src/index.js"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
@@ -75,53 +75,6 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
project: "vertex-project",
}).model("gemini-3.5-flash"),
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "provider_call_1" } },
}),
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "sunny",
resultType: "text",
providerMetadata: { google: { functionCallId: "provider_call_1" } },
}),
],
}),
)
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
expect(prepared.body.contents).toMatchObject([
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
],
},
])
}),
)
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
@@ -143,7 +96,7 @@ describe("Google Vertex providers", () => {
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
expect(request.headers.get("anthropic-version")).toBeNull()
const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({
anthropic_version: "vertex-2023-10-16",
@@ -237,7 +190,6 @@ describe("Google Vertex providers", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -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({
@@ -132,79 +93,7 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// The baseline does not enforce a provider id grammar, so a
// non-OpenAI but well-formed token is resent as-is.
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
// Shape violations are dropped even without a grammar policy.
{
type: "text",
text: "Dropped.",
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
id: "history_1",
role: "assistant",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Dropped." }],
},
])
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
// Raw reasoning finals carry no summary index; they reconcile
// into the item's first block.
{ type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw chain of thought.")
}),
)
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
@@ -224,14 +113,7 @@ describe("Open Responses-compatible route", () => {
)
expect(prepared.body).toMatchObject({
input: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
],
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
@@ -305,7 +187,6 @@ describe("Open Responses-compatible route", () => {
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
serviceTier: "provider-tier",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
@@ -330,7 +211,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",
@@ -28,7 +28,7 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
@@ -68,7 +68,7 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
return OpenAIResponsesChannel.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
@@ -188,15 +188,17 @@ 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")
}),
)
it.effect("preserves function schemas", () =>
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
@@ -236,22 +238,13 @@ describe("OpenAI Responses route", () => {
strict: false,
parameters: {
type: "object",
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
limit: { type: "integer", maximum: 2000 },
},
required: ["path"],
},
{
type: "object",
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
required: ["resource"],
},
],
properties: {
path: { type: "string" },
reference: { type: "string" },
limit: { type: "integer", maximum: 2000 },
resource: { type: "string" },
},
additionalProperties: false,
},
},
])
@@ -321,10 +314,6 @@ describe("OpenAI Responses route", () => {
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({
type: "response.output_item.added",
item: { type: "message", id: "msg_1" },
}),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
@@ -405,38 +394,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
}),
]),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
)
expect(response.finishReason?.normalized).toBe("stop")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
@@ -734,134 +691,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe("Bearer test")
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "grok-4.5",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.finishReason.normalized).toBe("stop")
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "deployment",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
)
}),
)
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
Effect.gen(function* () {
const cases = [
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
apiVersion: "2025-04-01-preview",
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
},
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
useDeploymentBasedUrls: true,
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
},
{
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
"deployment",
),
url: "https://gateway.example/azure/responses",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe(item.url)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -1348,7 +1177,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1376,7 +1205,8 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
])
}),
@@ -1406,60 +1236,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers remote tool-result media URLs without base64 wrapping", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [
{ type: "file", uri: "https://example.com/image.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/report.pdf", mime: "application/pdf", name: "report.pdf" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
])
}),
)
it.effect("lowers tool-result videos as input_video", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "record", input: {} })]),
Message.tool({
id: "call_1",
name: "record",
resultType: "content",
result: [
{ type: "file", uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" },
{ type: "file", uri: "https://example.com/demo.mp4", mime: "video/mp4" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_video", video_url: "data:video/mp4;base64,AAECAw==" },
{ type: "input_video", video_url: "https://example.com/demo.mp4" },
])
}),
)
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1669,7 +1445,6 @@ describe("OpenAI Responses route", () => {
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
{
@@ -1931,67 +1706,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_missing", delta: "orphaned text" },
{ type: "response.refusal.delta", item_id: "refusal_missing", delta: "orphaned refusal" },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_missing",
summary_index: 0,
delta: "orphaned reasoning",
},
{
type: "response.reasoning_summary_part.added",
item_id: "rs_still_missing",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_still_missing",
summary_index: 0,
delta: "still orphaned reasoning",
},
{
type: "response.function_call_arguments.delta",
item_id: "fc_missing",
delta: '{"orphaned":true}',
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("")
expect(response.message.content).toEqual([])
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("rejects function argument deltas without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
@@ -2047,10 +1761,8 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
{ type: "response.output_text.done", item_id: "msg_1" },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2061,10 +1773,10 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First", providerMetadata: undefined },
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second", providerMetadata: undefined },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
@@ -2073,9 +1785,7 @@ describe("OpenAI Responses route", () => {
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1" },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2087,22 +1797,18 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
@@ -2114,7 +1820,6 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{
type: "response.output_item.done",
@@ -2134,7 +1839,7 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1:0",
id: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -2196,105 +1901,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
// No `.delta` events at all: the gateway sends the complete
// summary text in the `.done` final.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [response.message],
providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("does not duplicate reasoning finals after streamed deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" },
// Repeats the complete text, as the spec allows.
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
@@ -2374,7 +1980,6 @@ describe("OpenAI Responses route", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -2486,134 +2091,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted tool results with their text form", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Search."),
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("drops replayed item ids outside the server's grammar", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// Fails the message id prefix.
{
type: "text",
text: "Hello",
providerMetadata: { openai: { itemId: "history_1" } },
},
// Oversized for the Responses item id limit.
{
type: "text",
text: "World",
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
},
// Fails the reasoning id prefix, so the whole item is unreplayable
// statelessly and is skipped rather than sent malformed.
{
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "toolu_01A" } },
}),
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "Hello" },
{ type: "output_text", text: "World" },
],
},
{
type: "function_call",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
Effect.gen(function* () {
const hostedResult = (itemId: string) => [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: { status: "completed" } },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
@@ -2737,15 +2214,15 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: ':"weather"}' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2774,7 +2251,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-input-delta",
@@ -2792,7 +2269,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
@@ -2800,7 +2277,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "step-finish",
@@ -2821,7 +2298,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2835,7 +2312,7 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
@@ -2847,7 +2324,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
@@ -2855,7 +2332,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
@@ -2868,14 +2345,14 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2907,7 +2384,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2936,7 +2413,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2950,7 +2427,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "fc_item_1" } },
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
@@ -2994,47 +2471,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 = {
@@ -3156,7 +2592,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses standard inline file encoding for xAI user PDFs", () =>
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -3179,7 +2615,8 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
],
},
@@ -3212,37 +2649,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers remote user media URLs without base64 wrapping", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{
type: "media",
mediaType: "application/pdf",
data: "https://example.com/report.pdf",
filename: "report.pdf",
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
],
},
])
}),
)
it.effect("fails with a typed rate limit for provider error frames", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -3399,42 +2805,36 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to the raw payload when error is null", () =>
it.effect("falls back to a stable default when error is null", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"error":null')
expect(error.body).toBe(error.reason.message)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
}),
)
it.effect("classifies a detail-free error event as a transient provider failure", () =>
it.effect("falls back to a stable default 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.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
}),
)
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"resp_failed_3"')
expect(error.body).toBe(error.reason.message)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
}),
)
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
id: "xai",
name: "xAI Grok 4.5",
provider: "xai",
protocol: "xai-responses",
protocol: "openai-responses",
requires: "XAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
@@ -1,86 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("extends the Open Responses baseline directly", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.protocol).toBe("xai-responses")
}),
)
it.effect("parses xAI reasoning summaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "reasoning_1" },
},
// Grok streams reasoning with the standard summary event name.
{
type: "response.reasoning_summary_text.delta",
item_id: "reasoning_1",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
type: "reasoning",
text: "Considering.",
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "x_search_1",
name: "x_search",
input: { query: "news" },
providerExecuted: true,
})
}),
)
})
+1 -1
View File
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
const providerMetadata = { google: { functionCallId: "provider_call" } }
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
+31 -10
View File
@@ -50,7 +50,32 @@ describe("tool schema projections", () => {
})
})
it.effect("applies model compatibility without changing schema semantics", () =>
test("openai keeps one flat object top-level schema", () => {
expect(
ToolSchemaProjection.openAI({
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{ type: "object", properties: { resource: { type: "string" } } },
],
}),
).toEqual({
type: "object",
properties: {
path: { type: "string" },
maybe: { type: "string" },
resource: { type: "string" },
},
additionalProperties: false,
})
})
it.effect("applies model compatibility before protocol projection", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -82,15 +107,11 @@ describe("tool schema projections", () => {
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
},
],
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
additionalProperties: false,
})
}),
)
-6
View File
@@ -19,12 +19,6 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Typography
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
@@ -259,34 +259,6 @@ export function event(
return makeEvent(type, data)
}
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
return makeEvent("session.compaction.started", data)
}
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
return makeEvent("session.compaction.delta", data)
}
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
return makeEvent("session.compaction.ended", data)
}
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
return makeEvent("session.compaction.failed", data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
export function toolInputEnded(data: Extract<OpenCodeEvent, { type: "session.tool.input.ended" }>["data"]) {
return makeEvent("session.tool.input.ended", data)
}
export function toolCalled(data: Extract<OpenCodeEvent, { type: "session.tool.called" }>["data"]) {
return makeEvent("session.tool.called", data)
}
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
@@ -899,26 +871,8 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode", "company-gateway"],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -14,63 +13,10 @@ import {
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
type TimelineMessage,
} from "./fixture"
test("follows an expanded patch that arrives as the user reaches the bottom", async ({ page }) => {
const toolID = "prt_bottom_follow_patch"
const input = { patchText: "Update src/edit.ts" }
const timeline = await setupTimeline(page, {
messages: [
...history(20),
userMessage(),
assistantMessage([textPart("prt_bottom_follow_text", "Working")], { completed: false }),
],
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 300)
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 300 }))
element.scrollTop = element.scrollHeight
})
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await timeline.send(partUpdated(toolPart(toolID, "patch", "running", input)))
await timeline.send(
partUpdated(
toolPart(toolID, "patch", "completed", input, {
metadata: {
files: [
{
file: "src/edit.ts",
status: "modified",
patch: createTwoFilesPatch(
"a/src/edit.ts",
"b/src/edit.ts",
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index}\n`).join(""),
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index + 1}\n`).join(""),
),
additions: 40,
deletions: 40,
},
],
},
}),
),
)
await timeline.waitForPart(toolID)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_wheel_01_shell"
const followingID = "prt_wheel_02_following"
@@ -53,37 +53,6 @@ benchmark.describe("performance: first navigation paint", () => {
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { draftID })
await page.goto("/")
const draftHref = stressDraftHref(draftID)
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
await expect(draftTab).toHaveCount(1)
await draftTab.click()
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
await expect(sessionTab).toHaveCount(1)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: '[data-component="new-session"]',
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await sessionTab.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
@@ -5,11 +5,9 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -23,7 +21,6 @@ test("applies message latency after a list response gate is released", async ()
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
started.resolve()
return gate.promise
},
onMessages: (request) => events.push(request.phase),
@@ -34,18 +31,12 @@ test("applies message latency after a list response gate is released", async ()
})
const response = handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"])
const released = performance.now()
@@ -54,42 +45,3 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})
test("routes requests through the HttpApi contract", async () => {
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
onConnectKey: connected.resolve,
})
const body = Buffer.from(JSON.stringify({ key: "secret" }))
let status: number | undefined
await handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
method: () => "POST",
headers: () => ({ "content-type": "application/json" }),
postDataBuffer: () => body,
}),
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
status = response?.status
return Promise.resolve()
},
} as unknown as Route)
expect(status).toBe(204)
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
})
@@ -1,106 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === sessionID)
const payload: unknown = route.request().postDataJSON()
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
throw new Error("Invalid rename payload")
if (session) session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
await row.focus()
await row.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(row).toBeFocused()
const rowBox = await row.boundingBox()
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
await page.getByRole("menuitem", { name: "Rename" }).click()
const title = page.locator('[data-component="home-session-rename"]')
await expect(title).toBeFocused()
await expect(title).toHaveValue(fixture.expected.targetTitle)
const editorBox = await title.boundingBox()
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
expect(editorBox?.x).toBe(titleBox?.x)
expect(editingAvatarBox).toEqual(avatarBox)
expect(
await title.evaluate((element) => ({
outline: getComputedStyle(element).outlineStyle,
shadow: getComputedStyle(element).boxShadow,
})),
).toEqual({ outline: "none", shadow: "none" })
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
await title.fill("Renamed from Home")
const renamed = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
)
await title.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(page).toHaveURL("/")
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
)
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
})
@@ -60,7 +60,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
if (path) return []
return [
{
name: "",
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
@@ -116,7 +116,6 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
@@ -13,21 +13,16 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// Server A has no tab and is never visited: a pending request there proves
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
})
await mockServers(page, permissionRequests)
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-dialog")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -43,17 +38,7 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
},
])
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
@@ -78,7 +63,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -158,99 +143,6 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
})
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingA: MockPermission[] = []
const listFailures: Record<string, number> = {}
const sessionGets: string[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: pendingA },
listFailures,
sessionGets,
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
// This request is asked while the client is disconnected, so it is never
// delivered as an event and only a reconnect sweep can find it. The first
// listing after the reconnect fails, so only the bounded sweep retry can
// deliver the reply.
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
listFailures[serverA] = 1
const syncsBeforeReconnect = sessionGets.length
await transport.disconnect()
await transport.waitForConnection({ after: first.id })
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
// cached locations, since another client may have moved them meanwhile.
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
})
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// The request is only served from the per-session permission list, so it
// reaches the client through the store sync when the session view opens,
// never through a location sweep or an event.
await mockServers(page, permissionRequests, permissionResponses, {
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
@@ -259,19 +151,6 @@ type PermissionResponse = {
body: unknown
}
type MockPermission = {
id: string
sessionID: string
action: string
resources: string[]
metadata: Record<string, unknown>
save: unknown[]
}
function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
@@ -282,23 +161,7 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}
type MockServerOptions = {
// Pending requests served from /api/permission/request, keyed by origin.
pending?: Record<string, MockPermission[]>
// Pending requests served from /api/session/:id/permission, keyed by session ID.
sessionPending?: Record<string, MockPermission[]>
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
}
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -315,12 +178,8 @@ async function mockServers(
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
// The generated client requires exactly 204 for a successful reply.
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
return json(route, true)
}
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/provider")
return json(route, {
@@ -338,12 +197,7 @@ async function mockServers(
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
const failures = options.listFailures?.[url.origin] ?? 0
if (failures > 0) {
options.listFailures![url.origin] = failures - 1
return json(route, { name: "Internal" }, 500)
}
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
return json(route, { location: { directory }, data: [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
@@ -365,13 +219,9 @@ async function mockServers(
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active")
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) {
options.sessionGets?.push(currentSessionInfo.id)
return json(route, { data: currentSession(currentSessionInfo) })
}
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
@@ -45,17 +45,21 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a diff line is hovered", async ({ page }) => {
test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -18,7 +18,7 @@ const branchDiffs = [
),
]
test("uses side placement by default and supports the terminal across the bottom", async ({ page }) => {
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
@@ -27,7 +27,7 @@ test("uses side placement by default and supports the terminal across the bottom
id: projectID,
worktree: directory,
vcs: "git",
name: "review-terminal-bottom",
name: "review-terminal-stacked",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
@@ -45,7 +45,7 @@ test("uses side placement by default and supports the terminal across the bottom
sessions: [
{
id: sessionID,
slug: "review-terminal-bottom",
slug: "review-terminal-stacked",
projectID,
directory,
title,
@@ -138,22 +138,7 @@ test("uses side placement by default and supports the terminal across the bottom
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectSideGeometry(page)
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
)
})
await page.reload()
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("#review-panel")).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectBottomGeometry(page)
await expectStackGeometry(page)
})
async function expectTree(page: Page, total: number, file: string) {
@@ -178,84 +163,26 @@ async function expectMountedTree(page: Page, total: number) {
expect(state.rows).toBeLessThanOrEqual(60)
}
async function expectSideGeometry(page: Page) {
await expectPanelGap(page, 8)
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
return {
reviewLeft: review.left,
reviewRight: review.right,
terminalLeft: terminal.left,
terminalRight: terminal.right,
terminalTop: terminal.top,
terminalBottom: terminal.bottom,
reviewTop: review.top,
reviewBottom: review.bottom,
}
})
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
}
async function expectBottomGeometry(page: Page) {
await expectPanelGap(page, 8)
async function expectStackGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const terminalRect = terminal.getBoundingClientRect()
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
const sidebar = review.querySelector<HTMLElement>('[data-slot="session-review-v2-sidebar"]')!
return {
review: review.getBoundingClientRect().height,
reviewBottom: review.getBoundingClientRect().bottom,
reviewParent: reviewParent.height,
terminal: terminalRect.height,
terminalLeft: terminalRect.left,
terminalRight: terminalRect.right,
terminalTop: terminalRect.top,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
sidebar: sidebar.getBoundingClientRect().width,
viewport: window.innerWidth,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
expect(geometry.terminalLeft).toBeLessThanOrEqual(9)
expect(geometry.terminalRight).toBeGreaterThanOrEqual(geometry.viewport - 9)
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
}
async function expectPanelGap(page: Page, expected: number) {
await expect
.poll(() => {
return page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.NEGATIVE_INFINITY
const gap = terminal.top - review.bottom
return gap
})
})
.toBeGreaterThanOrEqual(expected - 1)
await expect
.poll(() => {
return page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.POSITIVE_INFINITY
return terminal.top - review.bottom
})
})
.toBeLessThanOrEqual(expected + 1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,48 +0,0 @@
import { expect, test, type Route } from "@playwright/test"
const server = "http://127.0.0.1:4097"
test("nested server dialog keeps focus inside the top layer", async ({ page }) => {
await page.addInitScript((server) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] }))
}, server)
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/api/event") {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/global/health" || url.pathname === "/api/health") {
return json(route, { healthy: true, version: "2.0.0" })
}
return json(route, {})
})
await page.goto("/")
await page.keyboard.press("Control+,")
const settings = page.locator(".settings-dialog")
await expect(settings).toBeVisible()
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
const username = editor.getByPlaceholder("username")
const password = editor.getByPlaceholder("password")
await username.click()
await expect(username).toBeFocused()
await username.fill("kit")
await expect(username).toHaveValue("kit")
await page.keyboard.press("Tab")
await expect(password).toBeFocused()
await password.fill("secret")
await expect(password).toHaveValue("secret")
})
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) })
}
@@ -99,7 +99,7 @@ test.describe("regression: session timeline local row state", () => {
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-scope="apply-patch"] button').click()
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push(...textEvents())
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -179,8 +179,8 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
const trigger = wrapper.locator('[data-component="sticky-accordion-header"]')
const diff = wrapper.locator('[data-component="apply-patch-file-diff"]').first()
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const diff = wrapper.locator('[data-component="edit-content"]').first()
await expectAppVisible(diff)
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
const samples = await wrapper.evaluate(async (element) => {
@@ -190,8 +190,8 @@ test.describe("regression: session timeline local row state", () => {
for (const offset of [0, 120, 240, 360, 480]) {
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
await new Promise(requestAnimationFrame)
const trigger = element.querySelector<HTMLElement>('[data-component="sticky-accordion-header"]')!
const diff = element.querySelector<HTMLElement>('[data-component="apply-patch-file-diff"]')!
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
result.push({
offset,
trigger: trigger.getBoundingClientRect().y,
@@ -202,7 +202,7 @@ test.describe("regression: session timeline local row state", () => {
return result
})
expect(samples[0]!.trigger).toBeGreaterThanOrEqual(samples[0]!.diff)
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
})
@@ -234,9 +234,7 @@ async function readToolState(page: Page) {
.evaluate(
(element, textPartID) => ({
expanded: (() => {
const trigger =
element.querySelector('[data-scope="apply-patch"] button') ??
element.querySelector('[data-slot="collapsible-trigger"]')
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
@@ -411,9 +409,7 @@ function eventValue<Type extends OpenCodeEvent["type"]>(
}
function readExpanded(element: Element) {
const trigger =
element.querySelector('[data-scope="apply-patch"] button') ??
element.querySelector('[data-slot="collapsible-trigger"]')
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
@@ -1,12 +1,5 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
toolPart,
userMessage,
userText,
} from "../performance/timeline-stability/fixture"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
@@ -54,144 +47,5 @@ test("renders a completed single-file patch", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const file = wrapper.locator('[data-scope="apply-patch"]')
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("keeps an expanded file diff header at the same viewport position", async ({ page }) => {
const id = "prt_file_projection_anchored_patch"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
toolPart(
id,
"patch",
"completed",
{ patchText: "Update src/anchored.ts" },
{
metadata: {
files: [
{
file: "src/anchored.ts",
status: "modified",
patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after),
additions: 80,
deletions: 80,
},
],
},
},
),
]),
],
viewport: { width: 1200, height: 600 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(1)
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
})
await expect(trigger).toBeInViewport()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(0.5)
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, -20)
await expect
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
.toBeGreaterThan(0)
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element, collapsed) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
const allocatedHeight = element.getBoundingClientRect().height
return {
grew: allocatedHeight > collapsed + 1,
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
}
}, collapsedHeight),
)
.toEqual({ grew: true, measured: true })
await expect
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
.toBeLessThanOrEqual(5)
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, 200)
await expect
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
.toBeGreaterThan(50)
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
expect(scrolled).toBeLessThan(400)
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await expect
.poll(() =>
scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
)
.toBeLessThanOrEqual(1)
await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
})
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
@@ -21,17 +21,15 @@ test("keeps patch file disclosures independent", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "false")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "true")
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 20
const messagePageSize = 200
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
completedAssistantInfo,
messageUpdated,
@@ -9,13 +8,9 @@ import {
renderedPartID,
setupTimeline,
shell,
sessionID,
status,
stepStarted,
textPart,
toolCalled,
toolInputEnded,
toolInputStarted,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -39,56 +34,7 @@ for (const expanded of [false, true]) {
})
}
test("transitions a streaming shell from writing through command execution", async ({ page }) => {
const id = "prt_shell_streaming_input"
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const titleShimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(titleShimmer).toHaveAttribute("aria-label", "Shell")
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
const input = JSON.stringify({ command })
await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input }))
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText(command)
await expect(tool).not.toContainText("Writing command...")
await timeline.send(
toolCalled({
sessionID,
assistantMessageID: assistantID,
id,
input: { command },
executed: true,
}),
)
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText(command)
})
test("shimmers and expands a running shell command", async ({ page }) => {
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
await setupTimeline(page, {
@@ -98,10 +44,8 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
@@ -1,15 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import {
compactionDelta,
compactionEnded,
compactionFailed,
compactionStarted,
event,
session,
sessionID,
setupTimeline,
} from "../performance/timeline-stability/fixture"
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -82,160 +73,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
sessionID,
reason: "manual",
recent: "",
}),
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
sessionID,
text: "## Checkpoint\n\nStreamed implementation details.",
}),
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.send(
compactionEnded({
sessionID,
reason: "manual",
text: "## Checkpoint\n\nFinal implementation details.",
recent: "",
}),
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}),
)
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await timeline.send(
compactionFailed({
sessionID,
reason: "manual",
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
}),
)
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
@@ -244,24 +81,7 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
.toEqual({ aligned: true, ordered: true })
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
@@ -284,10 +104,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
@@ -298,14 +118,7 @@ test("shows a badge for active background work", async ({ page }) => {
sessionStatus: { [childID]: { type: "busy" } },
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
).toBeVisible()
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
@@ -380,15 +193,10 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const dock = page.locator('[data-component="session-background-dock"]')
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,7 +1,6 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
status,
toolPart,
@@ -70,126 +69,9 @@ test.describe("session timeline projection", () => {
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible()
await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"patch",
"completed",
{ patchText: "Update src/first.ts" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
})
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.patchRow = "stable"
})
await timeline.send(
partUpdated(toolPart(second, "patch", "running", { patchText: "Update more files" }, { metadata: {} })),
)
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable")
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute(
"aria-expanded",
"true",
)
await timeline.send(
partUpdated(
toolPart(
second,
"patch",
"completed",
{ patchText: "Update more files" },
{
metadata: {
files: [patchFile("src/first.ts", "modified"), patchFile("src/second.ts", "added")],
},
},
),
),
)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-scope="apply-patch"] [data-type="add"] button')).toHaveAttribute(
"aria-expanded",
"false",
)
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
const first = "prt_edit_first"
const second = "prt_edit_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
toolPart(
second,
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
settings: { editToolPartsExpanded: true },
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -250,43 +132,6 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
@@ -351,7 +196,11 @@ function patchPart(id: string) {
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
},
},
)
@@ -98,9 +98,9 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
const rows = group.locator('[data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
@@ -111,11 +111,7 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
@@ -125,23 +121,25 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
for (const [id, name] of [
[pending, "frontend-design"],
[completed, "OpenCode"],
] as const) {
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
for (const id of [pending, completed]) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible()
}
})
@@ -26,42 +26,6 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("returns to the parent session with Escape", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.keyboard.press("Escape")
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
})
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -86,44 +50,6 @@ test("keeps the parent visible while the child session resolves", async ({ page
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.goBack()
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
const parentTab = page.locator("[data-titlebar-tab-slot]", {
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
})
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(parentTab).toHaveAttribute("data-active", "true"),
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
const home = page.getByRole("button", { name: "Home" })
await home.click()
await expect(page).toHaveURL("/")
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
await expect(childTab).toHaveCount(1)
await childTab.click()
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))
@@ -9,14 +9,10 @@ const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const ptyInput: string[] = []
let sendPtyOutput: ((data: string) => void) | undefined
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
ptyInput.length = 0
sendPtyOutput = undefined
await mockOpenCodeServer(page, {
directory,
project: {
@@ -74,50 +70,7 @@ test.beforeEach(async ({ page }) => {
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}),
)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
ws.onMessage((message) => ptyInput.push(message.toString()))
sendPtyOutput = (data) => ws.send(data)
})
})
test("clears the terminal line with Command+Delete", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.keyboard.press("Meta+Backspace")
await expect.poll(() => ptyInput.join("")).toBe("\x15")
})
test("hides the native contenteditable caret", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)")
})
test("reveals the terminal after its first server output renders", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("opacity", "0")
await expect.poll(() => sendPtyOutput).toBeDefined()
sendPtyOutput?.("\x1b[?25h")
await expect(terminal).toHaveCSS("opacity", "0")
sendPtyOutput?.("ready")
await expect(terminal).toHaveCSS("opacity", "1")
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
@@ -243,8 +196,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
await page.getByRole("button", { name: "New terminal" }).click()
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
function seedCachedTerminal(page: Page) {
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -42,16 +42,6 @@ test("animates review and terminal panels while caching hidden terminal content"
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/animation.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty*", (route) =>
@@ -104,454 +94,24 @@ test("animates review and terminal panels while caching hidden terminal content"
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await installMotionProbe(page)
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expectWidthMotions(page, 1)
await expectReviewWidthStable(page)
await expectLogicalSideAlignment(page, "ltr")
await page.evaluate(() => (document.documentElement.dir = "rtl"))
await expectLogicalSideAlignment(page, "rtl")
await page.evaluate(() => (document.documentElement.dir = "ltr"))
await page.keyboard.press("Control+Backquote")
const panel = page.locator("#terminal-panel")
const terminalContent = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(terminalContent).toBeVisible()
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expectHeightMotions(page, "session-side-region", 1)
await expectHeightMotions(page, "session-side-terminal-region", 1)
await expectStackedGeometry(page)
await expectPanelGapHeld(page)
await resetTerminalTopMotion(page)
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(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
await expectTerminalTopMotion(page)
await expectTerminalBottomFixed(page)
await expectTerminalTopAnchored(page)
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)
await resetTerminalContentSizes(page)
await resetPanelGaps(page)
await page.keyboard.press("Control+Backquote")
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
await expectHeightMotions(page, "session-side-region", 4)
await expectHeightMotions(page, "session-side-terminal-region", 4)
await expect(panel).toBeHidden()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectTerminalContentCachedSize(page)
await expectStackPainted(page)
await expectPanelGapHeld(page)
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
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 expectNoHeightMotion(page)
await expectNoHorizontalScroll(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeHidden()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectWidthMotions(page, 4)
await expect(panel).toHaveCount(0)
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(terminalContent).toBeVisible()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectWidthMotions(page, 5)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeHidden()
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
)
})
await page.reload()
await expectSessionTitle(page, title)
await installMotionProbe(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expectAnimation(page, "terminal-panel-size-in")
await page.keyboard.press("Control+Backquote")
await expectAnimation(page, "terminal-panel-size-out")
await expect(panel).toBeHidden()
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
})
type MotionProbe = {
widths: number
widthEnds: number
horizontalScrolls: number[]
reviewWidths: number[]
paintGaps: { review: number; terminalSurface: number }[]
terminalContentSizes: { width: number; height: number }[]
terminalAnchorGaps: number[]
resetAnchorOnMotion: boolean
panelGaps: number[]
terminalTops: number[]
terminalBottoms: number[]
heights: string[]
animations: string[]
}
async function installMotionProbe(page: Page) {
await page.evaluate(() => {
const probe: MotionProbe = {
widths: 0,
widthEnds: 0,
horizontalScrolls: [],
reviewWidths: [],
paintGaps: [],
terminalContentSizes: [],
terminalAnchorGaps: [],
resetAnchorOnMotion: false,
panelGaps: [],
terminalTops: [],
terminalBottoms: [],
heights: [],
animations: [],
}
const observed = new WeakSet<Element>()
const observers: ResizeObserver[] = []
const observeReview = () => {
const review = document.querySelector('[data-component="session-review-v2"]')
if (!review || observed.has(review)) return
observed.add(review)
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
observer.observe(review)
observers.push(observer)
}
const observedRegions = new WeakSet<Element>()
const observeStack = () => {
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
observedRegions.add(reviewRegion)
const observer = new ResizeObserver(() => {
const review = document.querySelector<HTMLElement>("#review-panel")
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
if (!terminal || !terminalContent) return
probe.terminalTops.push(terminal.getBoundingClientRect().top)
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
probe.terminalContentSizes.push({
width: terminalContent.getBoundingClientRect().width,
height: terminalContent.getBoundingClientRect().height,
})
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
if (probe.resetAnchorOnMotion) {
if (anchorGap > 8) return
probe.terminalAnchorGaps = []
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
terminalSurface: Math.abs(
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
),
})
})
observer.observe(reviewRegion)
observer.observe(terminalRegion)
observers.push(observer)
}
new MutationObserver(() => {
observeReview()
observeStack()
}).observe(document.body, { childList: true, subtree: true })
observeReview()
observeStack()
document.addEventListener("transitionrun", (event) => {
if (!(event.target instanceof Element)) return
const slot = event.target.getAttribute("data-slot")
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
if (event.propertyName === "height" && slot) {
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
})
}
async function expectWidthMotions(page: Page, count: number) {
await expect
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
.toBeGreaterThanOrEqual(count)
}
async function resetHeightMotions(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.heights = []
})
}
async function expectSideSlideSettled(page: Page, count: number) {
await expect
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
.toBeGreaterThanOrEqual(count)
}
async function expectNoHeightMotion(page: Page) {
const heights = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
)
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
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
.toBe(true)
await expect
.poll(() =>
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
)
.toBeGreaterThan(0)
const widths = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
)
expect(new Set(widths).size).toBe(1)
}
async function expectStackedGeometry(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.POSITIVE_INFINITY
return terminal.top - review.bottom
}),
)
.toBeLessThanOrEqual(9)
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.NEGATIVE_INFINITY
return terminal.top - review.bottom
}),
)
.toBeGreaterThanOrEqual(7)
}
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
await expect
.poll(() =>
page.evaluate((direction) => {
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
if (!frame || !content) return Number.POSITIVE_INFINITY
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
}, direction),
)
.toBeLessThanOrEqual(1)
}
async function expectStackPainted(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
}
async function resetTerminalTopMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalTops = []
})
}
async function resetTerminalBottomMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalBottoms = []
})
}
async function expectTerminalBottomFixed(page: Page) {
const bottoms = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
)
expect(bottoms.length).toBeGreaterThan(0)
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
}
async function resetTerminalAnchorGaps(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.resetAnchorOnMotion = true
})
}
async function resetPanelGaps(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.panelGaps = []
})
}
async function expectPanelGapHeld(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
}
async function expectTerminalTopAnchored(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
}
async function resetTerminalContentSizes(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalContentSizes = []
})
}
async function expectTerminalContentCachedSize(page: Page) {
const sizes = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
)
expect(sizes.length).toBeGreaterThan(0)
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
}
async function expectTerminalTopMotion(page: Page) {
const tops = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
)
const unique = [...new Set(tops)]
const range = Math.max(...unique) - Math.min(...unique)
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
}
async function expectHeightMotions(page: Page, slot: string, count: number) {
await expect
.poll(() =>
page.evaluate(
(slot) =>
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
.length ?? 0,
slot,
),
)
.toBeGreaterThanOrEqual(count)
}
async function expectAnimation(page: Page, name: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
name,
),
)
.toBe(true)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -358,8 +358,7 @@ test.describe("smoke: session timeline", () => {
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("bun typecheck")
await expect(shell.locator('[data-slot="bash-result"]')).not.toContainText("bun typecheck")
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
-249
View File
@@ -1,249 +0,0 @@
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
const Json = Schema.Json.pipe(
Schema.decodeTo(Schema.Unknown, {
decode: SchemaGetter.passthrough(),
encode: SchemaGetter.transform(jsonValue),
}),
HttpApiSchema.asJson(),
)
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
path: Schema.optional(Schema.String),
query: Schema.optional(Schema.String),
type: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
message: Schema.String,
}) {}
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
message: Schema.String,
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
.add(
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
params: { integrationID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
params: { integrationID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
.add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json }))
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
)
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionList", "/api/session", {
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
params: { ...SessionParams, formID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
params: { ...SessionParams, permissionID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", {
params: SessionParams,
payload: JsonPayload,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
params: { ...SessionParams, messageID: Schema.String },
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
params: SessionParams,
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
export const MockApi = HttpApi.make("mock").add(Group)
function jsonValue(value: unknown): Schema.Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map(jsonValue)
if (!value || typeof value !== "object") return null
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
)
}
+321 -318
View File
@@ -1,9 +1,5 @@
import type { Page } from "@playwright/test"
import type { Page, Route } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Duration, Effect, Layer } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
provider: unknown | (() => unknown)
@@ -43,8 +39,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const cursors = new Map<string, string>()
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript(
({ server, retry }) => {
@@ -131,331 +128,316 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
const body = route.request().postDataBuffer()
const response = await transport.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
if (response.status === 404 && url.origin !== server) return route.fallback()
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
const events = config.events?.()
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
})
.handleRaw("fsRead", (ctx) =>
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
}),
const path = url.pathname
if (path === "/api/event") {
const events = config.events?.()
return sse(
route,
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
config.eventRetry,
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
reference: () =>
Effect.succeed({
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
}),
agent: () =>
Effect.succeed({
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
skill: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
projectList: () => {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
}
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
}`,
})
},
},
worktreeRemove: () => noContent,
worktreeRefresh: () => noContent,
location: () => Effect.succeed(location(config)),
permissionRequests: () =>
Effect.succeed({
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
}),
formRequests: () =>
Effect.succeed({
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
}),
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
Effect.map((data) => ({ location: location(config), data })),
),
fsFind: (ctx) =>
Effect.promise(() =>
Promise.resolve(
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
),
).pipe(
Effect.map((entries) => ({
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})),
),
shell: () => Effect.succeed({ location: location(config), data: [] }),
ptyConnectToken: () =>
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
sessionList: (ctx) => {
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return (
!ctx.query.directory ||
location?.directory === ctx.query.directory ||
session.directory === ctx.query.directory
)
})
.filter((session) => {
if (ctx.query.parentID === undefined) return true
if (ctx.query.parentID === "null") return session.parentID === undefined
return session.parentID === ctx.query.parentID
})
.filter((session) =>
ctx.query.search === undefined
? true
: String(session.title ?? "")
.toLowerCase()
.includes(ctx.query.search.toLowerCase()),
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/provider")
return json(route, {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model")
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current")
return json(route, {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
})
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
const offset = Number(ctx.query.cursor ?? 0)
const limit = ctx.query.limit ?? 50
const data = ordered.slice(offset, offset + limit)
return Effect.succeed({
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
})
},
sessionCreate: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
: entries,
})
}
if (path === "/api/shell" && route.request().method() === "GET")
return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (path === "/api/session") {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
},
sessionActive: () => {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return Effect.succeed({
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
},
sessionGet: (ctx) => {
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
return session
? Effect.succeed({ data: currentSession(session, config.directory) })
: Effect.fail(new MockNotFound({ message: "Session not found" }))
},
sessionRemove: () => noContent,
sessionShell: () => noContent,
sessionForm: (ctx) => {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return Effect.succeed({
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
})
},
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return Effect.succeed({
data: permissions
.map(currentPermission)
.filter((permission) => permission.sessionID === ctx.params.sessionID),
})
},
sessionPermissionReply: () => noContent,
sessionRename: () => noContent,
sessionInterrupt: () => noContent,
sessionRevertStage: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const messageID = payload.messageID
if (typeof messageID !== "string") {
return Effect.fail(new MockBadRequest({ message: "Invalid revert request" }))
}
return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe(
Effect.as({ data: { messageID } }),
)
},
sessionRevertClear: () => noContent,
sessionRevertCommit: () => noContent,
messageGet: (ctx) =>
Effect.gen(function* () {
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
yield* delay
const message =
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
config
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
.items.find((item) => item.id === ctx.params.messageID)
if (!message) return yield* new MockNotFound({ message: "Message not found" })
return { data: message }
}),
messageList: (ctx) => {
const token = ctx.query.cursor
const before = token ? state.cursors.get(token) : undefined
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
return Effect.gen(function* () {
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
if (config.beforeMessagesResponse) {
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
}
yield* delay
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
if (cursor) state.cursors.set(cursor, pageData.cursor!)
return {
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
}
})
},
}),
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
if (sessionPermission && route.request().method() === "GET") {
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return json(route, {
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
})
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const revertStage = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/)?.[1]
if (revertStage && route.request().method() === "POST") {
const body = route.request().postDataJSON()
if (!body || typeof body !== "object" || !("messageID" in body) || typeof body.messageID !== "string") {
return json(route, { error: "Invalid revert request" }, undefined, 400)
}
config.onRevertStage?.({ sessionID: revertStage, messageID: body.messageID })
return json(route, { data: { messageID: body.messageID } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message =
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: message })
}
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
})
}
if (url.port === targetPort && targetPort !== appPort)
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
return route.fallback()
})
}
function location(config: MockServerConfig) {
@@ -501,7 +483,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
modelID: model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -613,3 +595,24 @@ function jsonValue(value: unknown): JsonValue | undefined {
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
+4 -7
View File
@@ -6,10 +6,10 @@
"exports": {
".": "./src/index.ts",
"./desktop": "./src/desktop.ts",
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -48,7 +48,6 @@
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
@@ -57,7 +56,6 @@
"@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:*",
@@ -69,7 +67,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
-11
View File
@@ -15,14 +15,3 @@
/*.css
Content-Type: text/css
/site.webmanifest
Content-Type: application/manifest+json
/sw.js
Content-Type: application/javascript
Cache-Control: no-cache
/registerSW.js
Content-Type: application/javascript
Cache-Control: no-cache
+117 -27
View File
@@ -4,26 +4,72 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import {
type Component,
createMemo,
createRenderEffect,
ErrorBoundary,
type JSX,
lazy,
type ParentProps,
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
import { GlobalProvider } from "@/runtime/server/runtime"
import { HighlightsProvider } from "@/shell/updates/highlights"
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { WslServersProvider } from "@/servers/wsl/context"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { requireServerKey } from "./utils/session-route"
export { preloadRoute }
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const conn = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
)
}
declare global {
interface Window {
__OPENCODE__?: {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
@@ -54,6 +100,39 @@ function BodyTypography() {
return null
}
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: language.t("command.logs.export"),
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
<Layout>{props.children}</Layout>
</LayoutProvider>
)
}
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
@@ -105,16 +184,14 @@ export function AppInterface(props: {
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</TabsProvider>
)
@@ -125,9 +202,22 @@ export function AppInterface(props: {
servers={props.servers}
>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</GlobalProvider>
</SettingsProvider>
</ServersProvider>
)
@@ -1,21 +1,20 @@
import { getFilename } from "@opencode-ai/util/path"
import type { Project } from "@/runtime/server/types"
import type { Project } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/shell/commands/command"
import { useFile } from "@/workspaces/files/model"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName, projectForSession } from "@/shell/layout/helpers"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { useServer } from "@/runtime/server/current"
import { looksLikeSessionID } from "@/session/search"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = {
id: string
@@ -148,7 +147,6 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
get: (sessionID, signal) => serverSDK.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -223,7 +221,6 @@ export function createServerSessionEntries(props: {
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
get: (sessionID: string, signal: AbortSignal) => Promise<SessionInfo>
untitled: () => string
category: () => string
}) {
@@ -256,36 +253,28 @@ export function createServerSessionEntries(props: {
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
return Promise.all([
props.load(search, current.signal).then(
(result) => result.data,
() => [],
),
looksLikeSessionID(search)
? props.get(search, current.signal).then(
(result) => [result],
() => [],
)
: Promise.resolve([]),
]).then(([listed, exact]) =>
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()]
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory),
category: props.category(),
directory: session.location.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
return props
.load(search, current.signal)
.then((result) =>
result.data
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory),
category: props.category(),
directory: session.location.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
.catch(() => [] as CommandPaletteEntry[])
}
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./tooltip-keybind"
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
describe("command tooltip keybinds", () => {
test("keeps localized review shortcut modifiers", () => {
@@ -3,8 +3,8 @@ import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
type Mem = Performance & {
memory?: {
@@ -88,9 +88,9 @@ function Cell(props: {
</div>
<div
classList={{
"uppercase font-bold tabular-nums": true,
"text-[11px] leading-text-tight": !!props.inline,
"text-[13px] leading-text-compact sm:text-[14px]": !props.inline,
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}

Some files were not shown because too many files have changed in this diff Show More