Compare commits

..
Author SHA1 Message Date
LukeParkerDev 8d419ac7af feat(browser): add experimental desktop browser 2026-08-27 12:48:12 +10:00
540 changed files with 16540 additions and 24694 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve provider metadata on assistant text when replaying conversation history through AI SDK providers.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/theme": patch
---
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Align the built-in review and system prompts with available documentation and code-search tools and the current Plan mode reminder.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Correct the compaction prompt to point existing summaries at the conversation history that follows them.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Propagate fatal Git ignore-check errors during index refresh so best-effort snapshot capture returns unavailable instead of treating the check as having no matches.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Rename the write, patch, and question tool formatting helpers from `toModelOutput` to `toModelContent` to match the result field they populate. Direct imports of these helpers must use the new name; generated content and declared machine output are unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": minor
---
Rename ModelResolver.supported to ModelResolver.hasPackage. Consumers of the old export must update the name; the predicate remains Boolean(model.package), checking only whether a catalog model declares a provider package, not whether it can be loaded. Default-model selection behavior is unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Forward plugin Session interrupt options so continuation requests reach the Session service.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Wake idle sessions when recovering background shell outcomes after a server restart. Admit shell outcomes before resuming background child sessions, while preserving restart retry budgets and completion-driven parent notifications.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve explicit function tool strictness in Copilot Responses requests.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
-8
View File
@@ -1,8 +0,0 @@
---
"@opencode-ai/core": patch
"@opencode-ai/app": patch
---
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Keep Google Vertex catalog settings serializable during model projection.
-6
View File
@@ -244,12 +244,6 @@ jobs:
CI: true
timeout-minutes: 30
- name: Verify service worker precaching and upgrades
if: env.E2E_ENABLED == 'true'
working-directory: packages/app
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
timeout-minutes: 5
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+6
View File
@@ -41,5 +41,11 @@
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+342 -159
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+1 -2
View File
@@ -63,8 +63,7 @@ new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
command: "bun turbo build",
output: "./dist",
},
})
+9 -7
View File
@@ -4,7 +4,7 @@
bun,
nodejs,
darwin,
callPackage,
electron_41,
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
@@ -13,7 +13,7 @@
opencode,
}:
let
electron = callPackage ./electron.nix { };
electron = electron_41;
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
@@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: {
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron derives X11 WM_CLASS from app.name.
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
@@ -67,10 +67,12 @@ stdenv.mkDerivation (finalAttrs: {
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
substituteInPlace \
packages/desktop/src/main/windows/appearance.ts \
packages/desktop/src/main/service/desktop-cli.ts \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = ''
-13
View File
@@ -1,13 +0,0 @@
{ callPackage, path }:
let
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
in
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
# fetchzip hashes the unpacked headers, not the release tarball.
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-vn4haT0uuD5tCbMD2F5IuDj+/XS/U/sVQ0bk5xJCbIM=",
"aarch64-linux": "sha256-XUGJ6hE7xvIyT53k5uCqaF/eGU4d4tIbuVuGrHU12Fg=",
"aarch64-darwin": "sha256-o3Os6rO8nGVVYs1aJBJAdYQaLbDsYNtGub4Gg1gHRQU=",
"x86_64-darwin": "sha256-YB00nH7WmgqRXvzA+zYEbaaik/vKheu3n/vJ6Ombx9k="
"x86_64-linux": "sha256-iYdVrLtyKmjlyypisF9SqzgyriWT90kSCh3crxw9AKU=",
"aarch64-linux": "sha256-BV2t4w5ujArbtSC/Qfm3gLzevQW9A6hMgOyPVp94g/o=",
"aarch64-darwin": "sha256-EwMq7zaxzzcsmH0Pjqu4ftGdcM8Lna8mvHgKzRcVI8g=",
"x86_64-darwin": "sha256-PokzxlkQy6JvHADF2ZMIIDI1u9ZjSNNedpmR9gvHS5c="
}
}
+18 -22
View File
@@ -49,14 +49,14 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.13",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -67,13 +67,10 @@
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/event-listener": "2.4.6",
"@solid-primitives/media": "2.3.6",
"@solid-primitives/resize-observer": "2.2.0",
"@solid-primitives/storage": "4.4.0",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.4",
"dompurify": "3.4.14",
"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",
@@ -84,9 +81,9 @@
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "18.0.11",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
"semver": "7.7.4",
"typescript": "5.8.2",
@@ -95,17 +92,17 @@
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.3.6",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "1.0.0",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.71.0",
"@sentry/vite-plugin": "5.4.0",
"solid-js": "1.9.15",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
@@ -161,8 +158,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"effect": "catalog:"
},
"patchedDependencies": {
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
@@ -170,7 +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",
"solid-js@1.9.15": "patches/solid-js@1.9.15.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",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
-8
View File
@@ -19,14 +19,6 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks.
## Errors
- `AIError` wraps a union of tagged reason errors. It stores only `reason`, derives `message` from the reason, and exposes the reason as its `cause`.
- Each reason owns its readable `message`, category-specific fields, and optional `body`, `http`, and underlying exception in `cause`.
- `reason.body` is the sole original-response or triggering-event payload field. Preserve original text before schema decoding removes fields; do not replace the complete event with only its nested error.
- `reason.http` describes an observed HTTP response with required `url`, `status`, and response `headers`. Do not invent status codes or derive a separate request ID from headers.
- Reclassification and transport recovery must preserve the reason's body, HTTP context, and underlying cause. Error `message` and `cause` are non-enumerable: copy them explicitly when constructing an enriched reason with its constructor or `AIErrorReason.make`.
## Architecture
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
+4 -5
View File
@@ -1,7 +1,7 @@
import { Effect, Schema } from "effect"
import {
HttpOptions,
InvalidRequestError,
InvalidRequestReason,
AIError,
ModelID,
ProviderID,
@@ -158,10 +158,9 @@ export function generate(input: ImageRequest | ImageRequestInput) {
try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) =>
new AIError({
reason: new InvalidRequestError({
message: error instanceof Error ? error.message : String(error),
cause: error,
}),
module: "Image",
method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
}),
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
}
+7 -4
View File
@@ -3,7 +3,7 @@ import { LLMClient, Service } from "./route/client.js"
import {
GenerationOptions,
HttpOptions,
InvalidProviderOutputError,
InvalidProviderOutputReason,
AIError,
LLMEvent,
LLMRequest,
@@ -116,7 +116,9 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
)
if (!call || !LLMEvent.is.toolCall(call))
return yield* new AIError({
reason: new InvalidProviderOutputError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
}),
})
@@ -124,9 +126,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
Effect.mapError(
(error) =>
new AIError({
reason: new InvalidProviderOutputError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: tool input failed schema decode: ${error.message}`,
cause: error,
}),
}),
),
@@ -1387,15 +1387,14 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
return message || type || "Anthropic Messages stream error"
}
const onError = (event: AnthropicEvent) => {
const message = providerErrorMessage(event)
const body = ProviderShared.encodeJson(event)
return Effect.fail(
const onError = (event: AnthropicEvent) =>
Effect.fail(
new AIError({
reason: classifyProviderFailure({ message, rawBody: body }),
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
}),
)
}
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
+8 -12
View File
@@ -374,12 +374,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
@@ -388,7 +382,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
if (content.length > 0) messages.push({ role: "assistant", content })
messages.push({ role: "assistant", content })
continue
}
@@ -651,13 +645,15 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.exception) {
const message =
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error"
const body = ProviderShared.encodeJson(event)
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message,
rawBody: body,
message:
event.exception.details.message ??
event.exception.details.originalMessage ??
"Bedrock Converse stream error",
code: event.exception.type,
}),
})
}
@@ -1,7 +1,6 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { Effect, Encoding, Stream } from "effect"
import { AIError, AIErrorReason } from "../schema/index.js"
import { Effect, Stream } from "effect"
import { Framing } from "../route/framing.js"
import { ProviderShared } from "./shared.js"
@@ -50,14 +49,10 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
`Failed to decode Bedrock Converse event-stream frame: ${
error instanceof Error ? error.message : String(error)
}`,
Encoding.encodeBase64(view.subarray(0, totalLength)),
error,
),
})
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
const payload = utf8.decode(decoded.body)
const body = ProviderShared.encodeJson({ headers: decoded.headers, body: payload })
const messageType = decoded.headers[":message-type"]?.value
if (messageType === "error") {
const code = decoded.headers[":error-code"]?.value
@@ -66,7 +61,6 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
route,
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
"Bedrock Converse event-stream error",
body,
)
}
const eventType =
@@ -76,6 +70,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
? decoded.headers[":exception-type"]?.value
: undefined
if (typeof eventType !== "string") continue
const payload = utf8.decode(decoded.body)
if (!payload) continue
// The AWS event stream pads short payloads with a `p` field. Drop it
// before handing the object to the chunk schema. JSON decode goes
@@ -85,21 +80,11 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
route,
payload,
"Failed to parse Bedrock Converse event-stream payload",
).pipe(
Effect.mapError(
(error) =>
new AIError({
reason: AIErrorReason.make({ ...error.reason, message: error.message, cause: error.reason.cause, body }),
}),
),
)) as Record<string, unknown>
delete parsed.p
out.push({
...(messageType === "exception"
? { exception: { type: eventType, details: parsed } }
: { [eventType]: parsed }),
rawBody: body,
})
out.push(
messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed },
)
}
return [cursor, out] as const
})
@@ -112,7 +97,6 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
*/
export const framing = (route: string): Framing.Definition<object> => ({
id: "aws-event-stream",
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
})
-15
View File
@@ -6,7 +6,6 @@ import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
Usage,
type FinishReason,
@@ -18,7 +17,6 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
@@ -223,7 +221,6 @@ const GeminiPromptFeedback = Schema.StructWithRest(
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({
error: Schema.optional(Schema.Unknown),
candidates: optionalNull(Schema.Array(GeminiCandidate)),
promptFeedback: optionalNull(GeminiPromptFeedback),
usageMetadata: optionalNull(GeminiUsage),
@@ -601,18 +598,6 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message: event.error.message,
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
+34 -11
View File
@@ -9,7 +9,15 @@ import {
type ImageRoute,
} from "../image.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import { AIError, Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
type ProviderMetadata,
} from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
@@ -116,6 +124,13 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
)
}
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
@@ -154,9 +169,11 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
const output = yield* ProviderShared.imageResponse(ADAPTER, "Google Images", response)
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GoogleImageResponse))(output.body).pipe(
Effect.mapError((cause) => output.invalid("Google Images returned an invalid response", cause)),
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
)
const candidates = decoded.candidates ?? []
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
@@ -191,10 +208,9 @@ export const model = (input: ModelInput) => {
)
const images = yield* Effect.forEach(encoded, (item) =>
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
Effect.mapError((cause) =>
output.invalid(
Effect.mapError(() =>
invalidOutput(
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
cause,
),
),
Effect.map(
@@ -221,10 +237,16 @@ export const model = (input: ModelInput) => {
const finishReasons = candidates.flatMap((candidate) =>
candidate.finishReason === undefined ? [] : [candidate.finishReason],
)
return yield* output.invalid(
return yield* invalidOutput(
`Google Images returned no final images${
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
}; inspect body for prompt feedback and candidate details`,
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
{
google: {
promptFeedback: decoded.promptFeedback,
candidates: candidateMetadata,
},
},
)
}
const usage = decoded.usageMetadata
@@ -268,11 +290,12 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
if (image.type === "url")
return ImageInputs.decodeDataUrl(image.url).pipe(
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
Effect.flatMap((decoded) => {
if (decoded === undefined)
return Effect.fail(
ImageInputs.invalid(
ADAPTER,
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
),
)
@@ -282,7 +305,7 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
}),
)
return Effect.fail(
ImageInputs.invalid("Google generateContent requires Gemini file URIs rather than provider file IDs"),
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
)
}
@@ -1,7 +1,6 @@
import { Effect, Schema, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Framing } from "../route/framing.js"
import type { HttpContext } from "../schema/index.js"
import {
HttpTransport,
WebSocketTransport,
@@ -61,8 +60,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
Effect.mapError(() =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
),
)
if (terminal)
@@ -74,13 +73,13 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
Effect.mapError(() =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
}
}
if (event.type === "response.failed") {
@@ -93,7 +92,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} response failed`, frame),
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
}
}
if (event.type === "response.created") {
@@ -176,37 +175,23 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
channel,
}
}),
execute: (prepared, request, runtime, executeOptions) =>
Effect.gen(function* () {
if (!executeOptions?.webSocket || !prepared.channel) return yield* http.execute(prepared.http, request, runtime)
let fallbackHttp: HttpContext | undefined
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
connect: {
url: prepared.channel.url,
headers: prepared.channel.headers,
rotateAfterMs: prepared.channel.rotateAfterMs,
},
fallback: () =>
Stream.unwrap(
http.execute(prepared.http, request, runtime).pipe(
Effect.map((execution) => {
fallbackHttp = execution.http
return execution.frames
}),
),
),
driver: prepared.channel.driver,
}
const execution = yield* executeOptions.webSocket.execute(exchange)
return {
frames: execution.frames,
complete: execution.complete,
get http() {
return fallbackHttp ?? execution.http
},
}
}),
execute: (prepared, request, runtime, executeOptions) => {
if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
connect: {
url: prepared.channel.url,
headers: prepared.channel.headers,
rotateAfterMs: prepared.channel.rotateAfterMs,
},
fallback: () =>
Stream.unwrap(
http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
),
driver: prepared.channel.driver,
}
return executeOptions.webSocket.execute(exchange)
},
}
}
@@ -1,4 +1,4 @@
import { AIError, TransportError } from "../schema/index.js"
import { AIError, TransportReason } from "../schema/index.js"
import type { ChannelCheckpoint, ChannelObservation, WebSocketChannelDriver } from "../route/transport/index.js"
import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js"
@@ -99,17 +99,17 @@ const incremental = (
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
input: DriverInput,
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
recovery: "retry-full" | "rotate-and-retry-full",
): ChannelObservation => ({
type: "rejected",
recovery,
error: new AIError({
reason: new TransportError({
module: input.id,
method: "stream",
reason: new TransportReason({
message: observation.error.message,
body: observation.error.reason.body,
http: observation.error.reason.http,
cause: observation.error.reason.cause,
transport: "websocket",
operation: "read",
phase: "receive",
@@ -137,16 +137,15 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
)
const observation = yield* input.base.observe(create, frame)
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
if (observation.type === "provider-failure") {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
if (rejection === "websocket_connection_limit_reached")
return rejected(input, observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
const responseID = event.response?.id
+18 -15
View File
@@ -5,7 +5,7 @@ import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
ProviderInternalError,
ProviderInternalReason,
Usage,
type FinishReason,
type JsonSchema,
@@ -1188,8 +1188,11 @@ const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload |
return message || code
}
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
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 status =
@@ -1204,11 +1207,18 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
event.response === undefined &&
summary === undefined &&
status === undefined
? new ProviderInternalError({ message, body })
: classifyProviderFailure({ message, status, rawBody: body })
return new AIError({ reason })
? new ProviderInternalReason({ message })
: classifyProviderFailure({ message, code, status, rawBody: body })
return new AIError({
module: id,
method: "stream",
body,
reason,
})
}
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
@@ -1276,18 +1286,11 @@ export const step = (state: ParserState, input: Event) => {
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
state.id,
`${state.name} returned a malformed error event`,
ProviderShared.encodeJson(event),
cause,
),
),
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+28 -26
View File
@@ -7,11 +7,10 @@ import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
AIError,
AIErrorReason,
InvalidProviderOutputError,
InvalidProviderOutputReason,
LLMEvent,
ProviderInternalError,
UnknownProviderError,
ProviderInternalReason,
UnknownProviderReason,
Usage,
type FinishReason,
type FinishReasonDetails,
@@ -785,22 +784,26 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
// Streaming parsers are small state machines: every event returns a new state
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
// because OpenAI streams JSON arguments across multiple deltas.
const finishReasonError = (event: OpenAIChatEvent, reason: AIError["reason"]) =>
new AIError({
module: ADAPTER,
method: "stream",
body: ProviderShared.encodeJson(event),
reason,
})
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
switch (reason) {
case "error":
return yield* new AIError({
reason: new UnknownProviderError({
message: "Provider reported an error (finish_reason: error)",
body: ProviderShared.encodeJson(event),
}),
})
return yield* finishReasonError(
event,
new UnknownProviderReason({ message: "Provider reported an error (finish_reason: error)" }),
)
case "network_error":
return yield* new AIError({
reason: new ProviderInternalError({
message: "Provider reported a network error (finish_reason: network_error)",
body: ProviderShared.encodeJson(event),
}),
})
return yield* finishReasonError(
event,
new ProviderInternalReason({ message: "Provider reported a network error (finish_reason: network_error)" }),
)
case "stop":
case "end":
return "stop" as const
@@ -933,8 +936,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
if (event.error) {
const body = ProviderShared.encodeJson(event)
return yield* new AIError({
module: ADAPTER,
method: "stream",
body,
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
@@ -1059,14 +1066,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result))
return yield* new AIError({
reason: AIErrorReason.make({
...result.reason,
message: result.message,
cause: result.reason.cause,
body: ProviderShared.encodeJson(event),
}),
})
return yield* ProviderShared.eventError(ADAPTER, result.reason.message, ProviderShared.encodeJson(event))
tools = result.tools
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(...result.events)
@@ -1110,9 +1110,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
message: "OpenAI Chat stream ended without finish_reason",
module: ADAPTER,
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "OpenAI Chat stream ended without finish_reason",
route: ADAPTER,
}),
})
+28 -12
View File
@@ -9,7 +9,14 @@ import {
type ImageRoute,
} from "../image.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -77,6 +84,13 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
}
}
const invalidOutput = (message: string) =>
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
@@ -90,12 +104,12 @@ export const model = (input: ModelInput) => {
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
const mask = request.options?.mask
if (mask !== undefined && (request.images?.length ?? 0) === 0)
return yield* ImageInputs.invalid("An OpenAI image mask requires at least one input image")
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
const http = mergeHttpOptions(request.model.http, request.http)
const sourceImages = request.images ?? []
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)
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.undefined
})
const multipartMask =
@@ -104,7 +118,7 @@ export const model = (input: ModelInput) => {
: mask.type === "bytes"
? { data: mask.data, mediaType: mask.mediaType }
: mask.type === "url"
? yield* ImageInputs.decodeDataUrl(mask.url)
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
: undefined
const useMultipart =
sourceImages.length > 0 &&
@@ -147,7 +161,7 @@ export const model = (input: ModelInput) => {
return undefined
})
if (references.some((image) => image === undefined))
return yield* ImageInputs.invalid("OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
const maskReference =
mask === undefined
? undefined
@@ -159,7 +173,7 @@ export const model = (input: ModelInput) => {
? { file_id: mask.id }
: undefined
if (mask !== undefined && maskReference === undefined)
return yield* ImageInputs.invalid("OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
const requestBody = mergeJsonRecords(
{
model: request.model.id,
@@ -195,9 +209,11 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
options: OpenAIImageOptions | undefined,
overlay: Record<string, unknown> | undefined,
) {
const output = yield* ProviderShared.imageResponse(ADAPTER, "OpenAI Images", response)
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OpenAIImageResponse))(output.body).pipe(
Effect.mapError((cause) => output.invalid("OpenAI Images returned an invalid response", cause)),
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
)
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
const format =
@@ -205,7 +221,7 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
const images = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError((cause) => output.invalid(`OpenAI Images result ${index} contains invalid base64 data`, cause)),
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
@@ -225,9 +241,9 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(output.invalid(`OpenAI Images result ${index} has neither image data nor a URL`))
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* output.invalid("OpenAI Images returned no images")
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
return new ImageResponse({
images,
usage:
@@ -149,9 +149,7 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64", undefined, cause),
),
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
)
const format = item.output_format ?? "png"
return {
+14 -39
View File
@@ -2,12 +2,11 @@ import { Buffer } from "node:buffer"
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
InvalidProviderOutputError,
InvalidRequestError,
InvalidProviderOutputReason,
InvalidRequestReason,
AIError,
HttpContext,
type ContentPart,
type LLMRequest,
type MediaPart,
@@ -97,15 +96,17 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
}
export const eventError = (route: string, message: string, body?: string, cause?: unknown) =>
export const eventError = (route: string, message: string, raw?: string) =>
new AIError({
reason: new InvalidProviderOutputError({ route, message, body, cause }),
module: "ProviderShared",
method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }),
})
export const parseJson = (route: string, input: string, message: string) =>
Effect.try({
try: () => decodeJson(input),
catch: (cause) => eventError(route, message, input, cause),
catch: () => eventError(route, message, input),
})
/**
@@ -232,7 +233,7 @@ export const sseFraming = (
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error) return yield* eventError("sse", error.message, chunk, error)
if (error) return yield* eventError("sse", error.message)
return [state, state.output.splice(0)] as const
}),
),
@@ -248,39 +249,13 @@ export const sseFraming = (
/**
* Canonical invalid-request constructor shared by protocol lowering.
*/
export const invalidRequest = (message: string, cause?: unknown) =>
export const invalidRequest = (message: string) =>
new AIError({
reason: new InvalidRequestError({ message, cause }),
module: "ProviderShared",
method: "request",
reason: new InvalidRequestReason({ message }),
})
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
response: HttpClientResponse.HttpClientResponse,
) {
const http = new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
const body = yield* response.text.pipe(
Effect.mapError(
(cause) =>
new AIError({
reason: new InvalidProviderOutputError({
route,
message: `Failed to read the ${name} response`,
http,
cause,
}),
}),
),
)
return {
body,
invalid: (message: string, cause?: unknown) =>
new AIError({
reason: new InvalidProviderOutputError({ route, message, body, http, cause }),
}),
}
})
export const matchToolChoice = <Auto, None, Required, Tool>(
route: string,
toolChoice: NonNullable<LLMRequest["toolChoice"]>,
@@ -327,7 +302,7 @@ export const unsupportedContent = (
export const validateWith =
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
(payload: I) =>
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message, error)))
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
/**
* Build an HTTP POST with a JSON body. Sets `content-type: application/json`
@@ -1,10 +1,12 @@
import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image.js"
import { InvalidRequestError, AIError } from "../../schema/index.js"
import { InvalidRequestReason, AIError } from "../../schema/index.js"
const invalid = (message: string, cause?: unknown) =>
const invalid = (module: string, message: string) =>
new AIError({
reason: new InvalidRequestError({ message, cause }),
module,
method: "generate",
reason: new InvalidRequestReason({ message }),
})
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
@@ -12,12 +14,13 @@ export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>)
export const decodeDataUrl = (
url: string,
module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.undefined
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid("Image data URLs must contain a MIME type and base64 data"))
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(
Effect.mapError((cause) => invalid("Image data URL contains invalid base64 data", cause)),
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
Effect.map((data) => ({ mediaType: match[1], data })),
)
}
+24 -10
View File
@@ -2,7 +2,14 @@ import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
@@ -87,6 +94,13 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
}
}
const invalidOutput = (message: string) =>
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
@@ -106,7 +120,7 @@ export const model = (input: ModelInput) => {
return undefined
})
if (imageReferences.some((image) => image === undefined))
return yield* ImageInputs.invalid("xAI Images accepts image URLs, data URLs, bytes, and file IDs")
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
const requestBody = mergeJsonRecords(
{
model: request.model.id,
@@ -135,17 +149,17 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
const output = yield* ProviderShared.imageResponse(ADAPTER, "xAI Images", response)
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(XAIImageResponse))(output.body).pipe(
Effect.mapError((cause) => output.invalid("xAI Images returned an invalid response", cause)),
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
)
const images = yield* Effect.forEach(decoded.data, (item, index) => {
const mediaType = item.mime_type ?? "application/octet-stream"
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError((cause) =>
output.invalid(`xAI Images result ${index} contains invalid base64 data`, cause),
),
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
@@ -169,9 +183,9 @@ export const model = (input: ModelInput) => {
: { xai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(output.invalid(`xAI Images result ${index} has neither image data nor a URL`))
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* output.invalid("xAI Images returned no images")
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
return new ImageResponse({
images,
+21 -6
View File
@@ -2,7 +2,13 @@ import { Effect, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import { mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
@@ -57,6 +63,13 @@ const nativeOptions = (options: ZAIImageOptions | undefined) => {
}
}
const invalidOutput = (message: string) =>
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
@@ -69,7 +82,7 @@ export const model = (input: ModelInput) => {
id: ADAPTER,
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
if ((request.images?.length ?? 0) > 0)
return yield* ImageInputs.invalid("Z.ai hosted image generation does not support image inputs")
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
const http = mergeHttpOptions(request.model.http, request.http)
const requestBody = mergeJsonRecords(
{ model: request.model.id, prompt: request.prompt },
@@ -91,11 +104,13 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
const output = yield* ProviderShared.imageResponse(ADAPTER, "Z.ai Images", response)
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ZAIImageResponse))(output.body).pipe(
Effect.mapError((cause) => output.invalid("Z.ai Images returned an invalid response", cause)),
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
)
if (decoded.data.length === 0) return yield* output.invalid("Z.ai Images returned no images")
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
)
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
return new ImageResponse({
images: decoded.data.map(
(item) =>
+43 -46
View File
@@ -1,16 +1,17 @@
import { Option, Schema } from "effect"
import {
AuthenticationError,
ContentPolicyError,
InvalidRequestError,
AuthenticationReason,
ContentPolicyReason,
InvalidRequestReason,
AIError,
ProviderErrorEvent,
ProviderInternalError,
QuotaExceededError,
RateLimitError,
UnknownProviderError,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
UnknownProviderReason,
type HttpContext,
type HttpRateLimitDetails,
type ProviderMetadata,
} from "./schema/index.js"
const patterns = [
@@ -79,28 +80,27 @@ 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
// Some SDKs supply parsed error data separately from the original response text.
readonly data?: unknown
readonly http?: HttpContext | undefined
readonly cause?: unknown
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http?: HttpContext | undefined
readonly providerMetadata?: ProviderMetadata | undefined
}
// 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 details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
const body = input.rawBody ?? ""
const codes = [...providerCodes(input.data), ...providerCodes(body), ...providerCodes(input.message)].map((code) =>
code.toLowerCase(),
)
const body = input.http?.body ?? input.rawBody ?? ""
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 common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
if (
@@ -110,65 +110,62 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
codes.includes("request_too_large") ||
isContextOverflow(text))
)
return new InvalidRequestError({ ...details, classification: "context-overflow" })
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
return new QuotaExceededReason(common)
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" })
if (codes.includes("permission_error"))
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
if (
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
)
return new RateLimitError({
...details,
return new RateLimitReason({
...common,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (RATE_LIMIT_TEXT.test(text))
return new RateLimitError({
...details,
return new RateLimitReason({
...common,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
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 ProviderInternalError({
...details,
return new ProviderInternalReason({
...common,
status: input.status,
retryAfterMs: input.retryAfterMs,
})
if (input.status === 429) {
return new RateLimitError({
...details,
return new RateLimitReason({
...common,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalError({
...details,
return new ProviderInternalReason({
...common,
status: input.status,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestError(details)
return new UnknownProviderError(details)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
function providerCodes(value: unknown) {
const decoded = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
function providerCodes(value: string) {
const decoded = Option.getOrUndefined(decodeJson(value))
if (!isRecord(decoded)) return []
const error = isRecord(decoded.error) ? decoded.error : undefined
const response = isRecord(decoded.response) ? decoded.response : undefined
const responseError = response && isRecord(response.error) ? response.error : undefined
const exception = isRecord(decoded.exception) ? decoded.exception : undefined
return [decoded.code, error?.code, error?.type, error?.status, responseError?.code, exception?.type].filter(
(value): value is string => typeof value === "string",
)
return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
}
function isRecord(value: unknown): value is Record<string, unknown> {
+5 -3
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema/index.js"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -137,10 +137,12 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
const toAIError = (error: AuthError): AIError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new AIError({
module: "Auth",
method: "apply",
reason:
error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
? new AuthenticationReason({ message: error.message, kind: "missing" })
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
})
}
return error
+10 -50
View File
@@ -12,14 +12,13 @@ import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
AIErrorReason,
GenerationOptions,
HttpOptions,
LLMRequest,
LLMResponse,
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
InvalidProviderOutputReason,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -228,14 +227,16 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof AIError) return failed
return ProviderShared.eventError(route, message, undefined, cause)
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const incompleteStreamError = (route: string) =>
new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended unexpectedly.",
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
@@ -264,12 +265,11 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
const decodeEvent = (route: string) => (frame: Frame) =>
decodeEventEffect(frame).pipe(
Effect.mapError((cause) =>
Effect.mapError(() =>
ProviderShared.eventError(
input.id,
`Invalid ${route} stream event`,
typeof frame === "string" ? frame : ProviderShared.encodeJson(frame),
cause,
),
),
)
@@ -324,48 +324,19 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return Stream.unwrap(
routeInput.transport.execute(prepared, request, runtime, options).pipe(
Effect.map((execution) => {
const terminal = protocol.stream.terminal
// Preserve assembled inputs; replace only serialized event fallbacks with their original wire data.
const frameError =
(frame: Frame, event: Frame | Event = frame) =>
(error: AIError) =>
new AIError({
reason: AIErrorReason.make({
...error.reason,
message: error.reason.message,
cause: error.reason.cause,
body:
error.reason.body !== undefined && error.reason.body !== ProviderShared.encodeJson(event)
? error.reason.body
: (execution.body?.(frame) ??
(typeof frame === "string" ? frame : ProviderShared.encodeJson(frame))),
}),
})
const events = execution.frames.pipe(
Stream.mapEffect((frame) =>
decodeEvent(route)(frame).pipe(
Effect.catchCause((cause) =>
Effect.fail(streamError(route, `Failed to decode ${route} event`, cause)),
),
Effect.map((event) => ({ event, frame })),
Effect.mapError(frameError(frame)),
),
),
terminal ? Stream.takeUntil(({ event }) => terminal(event)) : (stream) => stream,
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
const stream = Stream.suspend(() => {
let state = protocol.stream.initial(request)
const parsed = events.pipe(
Stream.mapEffect(({ event, frame }) =>
Stream.mapEffect((event) =>
protocol.stream.step(state, event).pipe(
Effect.catchCause((cause) =>
Effect.fail(streamError(route, `Failed to parse ${route} event`, cause)),
),
Effect.map(([next, output]) => {
state = next
return output
}),
Effect.mapError(frameError(frame, event)),
),
),
Stream.flatMap(Stream.fromIterable),
@@ -381,17 +352,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
}).pipe(
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
Stream.mapError(
(error) =>
new AIError({
reason: AIErrorReason.make({
...error.reason,
message: error.reason.message,
cause: error.reason.cause,
http: error.reason.http ?? execution.http,
}),
}),
),
)
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
}),
+92 -48
View File
@@ -7,7 +7,14 @@ import {
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
import {
HttpContext,
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
AIError,
TransportReason,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
export interface Interface {
@@ -90,13 +97,24 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
})
}
export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
new HttpContext({
url: response.request.url,
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
new HttpRequestDetails({
method: request.method,
url: request.url,
headers: headerDetails(request.headers),
})
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
new HttpResponseDetails({
status: response.status,
headers: headerDetails(response.headers),
})
const responseBody = (body: string | void) => {
if (body === undefined) return {}
return { body }
}
const decodeProviderBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
@@ -114,46 +132,77 @@ const providerMessage = (status: number, body: string | void) => {
)
}
const statusError = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
if (response.status < 400) return response
const result = yield* response.text.pipe(Effect.result)
return yield* httpFailure({
message: providerMessage(response.status, result._tag === "Success" ? result.success : undefined),
url: response.request.url,
status: response.status,
responseHeaders: headerDetails(response.headers),
responseBody: result._tag === "Success" ? result.success : undefined,
cause: result._tag === "Failure" ? (result.failure.cause ?? result.failure) : undefined,
})
const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly body: ReturnType<typeof responseBody>
readonly rateLimit?: HttpRateLimitDetails | undefined
}) =>
new HttpContext({
request: requestDetails(input.request),
response: responseDetails(input.response),
...input.body,
rateLimit: input.rateLimit,
})
/** Preserve HTTP diagnostics for executor and externally captured failures alike. */
export const httpFailure = (input: {
const statusError =
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
if (response.status < 400) return response
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body)
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
reason: classifyProviderFailure({
status: response.status,
message: providerMessage(response.status, body),
retryAfterMs: retryAfter,
rateLimit,
http: responseHttp({
request,
response,
body: details,
rateLimit,
}),
}),
})
})
// Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and HttpContext that
// executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST),
// request headers are empty.
export const classifyHttpFailure = (input: {
readonly message: string
readonly url?: string | undefined
readonly url: string
readonly status?: number | undefined
readonly data?: unknown
readonly code?: string | undefined
readonly responseHeaders?: Record<string, string> | undefined
readonly responseBody?: string | undefined
readonly cause?: unknown
}) => {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
return new AIError({
reason: classifyProviderFailure({
message: input.message,
status: input.status,
data: input.data,
rawBody: input.responseBody,
retryAfterMs: retryAfter,
rateLimit,
cause: input.cause,
http:
input.status === undefined || input.url === undefined
const details = responseBody(input.responseBody)
return classifyProviderFailure({
message: input.message,
status: input.status,
code: input.code,
retryAfterMs: retryAfter,
rateLimit,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
response:
input.status === undefined
? undefined
: new HttpContext({ url: input.url, status: input.status, headers }),
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details,
rateLimit,
}),
})
}
@@ -180,25 +229,25 @@ const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly http?: HttpContext
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
new AIError({
reason: new TransportError({
module: "RequestExecutor",
method: input.operation,
reason: new TransportReason({
message: failure.message,
cause: source,
http: input.http,
transport: "http",
operation: input.operation,
code: failure.code,
url: request.url,
http: new HttpContext({ request: requestDetails(request) }),
}),
})
const source =
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
? (input.error.reason.cause ?? input.error)
? input.error.reason.cause
: input.error
const native = nativeTransportFailure(source)
const code = native?.code
@@ -222,13 +271,6 @@ const httpError = (input: {
})
}
export const responseStream = (response: HttpClientResponse.HttpClientResponse): Stream.Stream<Uint8Array, AIError> =>
response.stream.pipe(
Stream.mapError((error) =>
httpError({ error, request: response.request, operation: "read", http: responseHttp(response) }),
),
)
export const stream = (
executor: Interface,
request: HttpClientRequest.HttpClientRequest,
@@ -237,7 +279,9 @@ export const stream = (
Stream.unwrap(
Effect.gen(function* () {
const response = yield* executor.execute(request, middleware)
return responseStream(response)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
)
}),
)
@@ -250,7 +294,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
if (!middleware)
return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
Effect.flatMap(statusError),
Effect.flatMap(statusError(request)),
)
const response = yield* middleware(request, (input) =>
@@ -258,7 +302,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
return yield* statusError(response)
return yield* statusError(response.request)(response)
})
return Service.of({
execute: executeOnce,
-2
View File
@@ -19,8 +19,6 @@ import type { AIError } from "../schema/index.js"
export interface Definition<Frame> {
readonly id: string
readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
/** Original wire representation when framing transforms the provider payload. */
readonly body?: (frame: Frame) => string | undefined
}
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+2 -7
View File
@@ -88,13 +88,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
}
}),
execute: (prepared, _request, runtime) =>
Effect.gen(function* () {
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
return {
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
http: RequestExecutor.responseHttp(response),
body: prepared.framing.body,
}
Effect.succeed({
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
}),
})
+1 -3
View File
@@ -3,7 +3,7 @@ import { Endpoint } from "../endpoint.js"
import { Auth } from "../auth.js"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js"
import type { WebSocketChannelExecutor } from "./websocket-channel.js"
import type { AIError, HttpContext, LLMRequest } from "../../schema/index.js"
import type { AIError, LLMRequest } from "../../schema/index.js"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
@@ -11,8 +11,6 @@ export interface TransportRuntime {
export interface TransportExecution<Frame> {
readonly frames: Stream.Stream<Frame, AIError>
readonly http?: HttpContext
body?(frame: Frame): string | undefined
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
readonly complete?: Effect.Effect<void>
}
@@ -1,6 +1,6 @@
import type { Effect, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import type { AIError, HttpContext } from "../../schema/index.js"
import type { AIError } from "../../schema/index.js"
export interface WebSocketChannelExecutor {
readonly execute: (
@@ -10,7 +10,6 @@ export interface WebSocketChannelExecutor {
export interface WebSocketChannelExecution {
readonly frames: Stream.Stream<string, AIError>
readonly http?: HttpContext
/** Commits staged state after the decoded Route stream ends successfully. */
readonly complete: Effect.Effect<void>
}
+33 -100
View File
@@ -1,13 +1,7 @@
import { Cause, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import {
AIError,
AIErrorReason,
TransportError,
type HttpContext,
type TransportOperation,
} from "../../schema/index.js"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
import type {
@@ -23,7 +17,6 @@ export interface WebSocketRequest {
}
export interface WebSocketConnection {
readonly http?: HttpContext
readonly sendText: (message: string) => Effect.Effect<void, AIError>
readonly messages: Stream.Stream<string | Uint8Array, AIError>
readonly close: Effect.Effect<void, never>
@@ -40,22 +33,21 @@ type WebSocketConstructorWithHeaders = (
const MAX_FRAME_BYTES = 16 * 1024 * 1024
const transportError = (
method: string,
message: string,
input: {
readonly operation: TransportOperation
readonly url?: string
readonly code?: string
readonly phase?: TransportError["phase"]
readonly delivery?: TransportError["delivery"]
readonly body?: string
readonly cause?: unknown
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) =>
new AIError({
reason: new TransportError({
module: "WebSocketConnector",
method,
reason: new TransportReason({
message,
body: input.body,
cause: input.cause,
transport: "websocket",
operation: input.operation,
url: input.url,
@@ -67,16 +59,22 @@ const transportError = (
const annotateTransportError = (
error: AIError,
input: { readonly phase: TransportError["phase"]; readonly delivery: TransportError["delivery"] },
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) =>
error.reason._tag === "Transport"
? new AIError({
reason: new TransportError({
...error.reason,
module: error.module,
method: error.method,
reason: new TransportReason({
message: error.reason.message,
cause: error.reason.cause,
transport: error.reason.transport,
operation: error.reason.operation,
code: error.reason.code,
url: error.reason.url,
http: error.reason.http,
phase: input.phase,
delivery: input.delivery,
recovery: error.reason.recovery,
}),
})
: error
@@ -97,7 +95,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void
if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) {
return Effect.fail(
transportError(`WebSocket closed before opening (state ${ws.readyState})`, {
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url,
operation: "request",
code: "closed",
@@ -126,8 +124,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError(`Failed to open WebSocket: ${eventMessage(event)}`, {
cause: "error" in event ? (event.error ?? event) : event,
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
phase: "connect",
@@ -140,9 +137,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError(`WebSocket closed before opening with code ${event.code}`, {
body: event.reason,
cause: event,
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url,
operation: "request",
code: String(event.code),
@@ -174,8 +169,7 @@ export const toWebSocketUrl = (value: string) =>
throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`)
},
catch: (error) =>
transportError(error instanceof Error ? error.message : "Invalid WebSocket URL", {
cause: error,
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value,
operation: "request",
code: "invalid-url",
@@ -195,8 +189,7 @@ export const open = (input: WebSocketRequest) =>
headers: input.headers,
}),
catch: (error) =>
transportError(error instanceof Error ? error.message : "Failed to construct WebSocket", {
cause: error,
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
operation: "request",
phase: "connect",
@@ -221,8 +214,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket message exceeds the 16 MiB limit", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
url: input.url,
operation: "read",
code: "message-too-large",
@@ -239,8 +231,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket inbound queue overflow", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
transportError("message", "WebSocket inbound queue overflow", {
url: input.url,
operation: "read",
code: "queue-overflow",
@@ -257,8 +248,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("Unsupported WebSocket message payload", {
cause: event,
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
code: "message",
@@ -271,8 +261,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError(`WebSocket error: ${eventMessage(event)}`, {
cause: "error" in event ? (event.error ?? event) : event,
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
code: "message",
@@ -285,9 +274,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError(`WebSocket closed with code ${event.code}`, {
body: event.reason,
cause: event,
transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
operation: "read",
code: String(event.code),
@@ -311,7 +298,7 @@ export const fromWebSocket = (
Effect.suspend(() => {
if (ws.readyState !== globalThis.WebSocket.OPEN)
return Effect.fail(
transportError(`WebSocket is not open (state ${ws.readyState})`, {
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url,
operation: "write",
phase: "send",
@@ -321,8 +308,7 @@ export const fromWebSocket = (
return Effect.try({
try: () => ws.send(message),
catch: (error) =>
transportError(error instanceof Error ? error.message : "Failed to send WebSocket message", {
cause: error,
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
operation: "write",
phase: "send",
@@ -363,23 +349,10 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
(connection) => connection.close,
)
const create = yield* exchange.driver.create(undefined)
yield* connection.sendText(create.message).pipe(
Effect.mapError(
(error) =>
new AIError({
reason: AIErrorReason.make({
...error.reason,
message: error.reason.message,
cause: error.reason.cause,
http: error.reason.http ?? connection.http,
}),
}),
),
)
yield* connection.sendText(create.message)
const decoder = new TextDecoder()
let observed = false
return {
http: connection.http,
frames: connection.messages.pipe(
Stream.map((message) => {
observed = true
@@ -391,49 +364,9 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
delivery: observed ? "accepted" : "ambiguous",
}),
),
Stream.mapEffect((frame) =>
exchange.driver.observe(create, frame).pipe(
Effect.mapError(
(error) =>
new AIError({
reason: AIErrorReason.make({
...error.reason,
message: error.reason.message,
cause: error.reason.cause,
body: frame,
}),
}),
),
Effect.map((observation) =>
"error" in observation
? {
...observation,
error: new AIError({
reason: AIErrorReason.make({
...observation.error.reason,
message: observation.error.reason.message,
cause: observation.error.reason.cause,
body: frame,
}),
}),
}
: observation,
),
),
),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.takeUntil(observationTerminal),
Stream.mapEffect(observationFrame),
Stream.mapError(
(error) =>
new AIError({
reason: AIErrorReason.make({
...error.reason,
message: error.reason.message,
cause: error.reason.cause,
http: error.reason.http ?? connection.http,
}),
}),
),
),
complete: Effect.void,
}
@@ -484,7 +417,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
const webSocket = options?.webSocket
if (!webSocket) {
return Effect.fail(
transportError("WebSocket JSON transport requires StreamOptions.webSocket", {
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
url: prepared.url,
operation: "request",
code: "unavailable",
@@ -502,7 +435,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
connect: { url: prepared.url, headers: prepared.headers },
fallback: () =>
Stream.fail(
transportError("WebSocket JSON transport does not provide HTTP fallback", {
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
url: prepared.url,
operation: "request",
code: "websocket",
+103 -72
View File
@@ -1,13 +1,19 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, RouteID } from "./ids.js"
import { ProviderMetadata } from "./messages.js"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })),
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
@@ -18,61 +24,73 @@ export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
const ReasonFields = {
message: Schema.String,
// Preserve the complete original response or triggering event before decoding narrows it.
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
request: HttpRequestDetails,
response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
cause: Schema.optional(Schema.Defect({ includeStack: true })),
}
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>("AI.Error.InvalidRequest")(
"InvalidRequest",
{
...ReasonFields,
parameter: Schema.optional(Schema.String),
classification: Schema.optional(ProviderFailureClassification),
},
) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {}
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
"Authentication",
{
...ReasonFields,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
},
) {}
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
bodyTruncated: Schema.optional(Schema.Boolean),
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
"QuotaExceeded",
ReasonFields,
) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"),
message: Schema.String,
parameter: Schema.optional(Schema.String),
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
"ContentPolicy",
ReasonFields,
) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
_tag: Schema.tag("NoRoute"),
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
get message() {
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
}
}
export class ProviderInternalError extends Schema.TaggedError<ProviderInternalError>("AI.Error.ProviderInternal")(
"ProviderInternal",
{
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
},
) {}
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
_tag: Schema.tag("Authentication"),
message: Schema.String,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
_tag: Schema.tag("RateLimit"),
message: Schema.String,
retryAfterMs: Schema.optional(Schema.Number),
rateLimit: Schema.optional(HttpRateLimitDetails),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"),
message: Schema.String,
status: Schema.optional(Schema.Number),
retryAfterMs: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
@@ -80,12 +98,14 @@ export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportError extends Schema.TaggedError<TransportError>("AI.Error.Transport")("Transport", {
...ReasonFields,
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
transport: TransportType,
operation: TransportOperation,
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
phase: Schema.optional(
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
),
@@ -95,40 +115,51 @@ export class TransportError extends Schema.TaggedError<TransportError>("AI.Error
),
}) {}
export class InvalidProviderOutputError extends Schema.TaggedError<InvalidProviderOutputError>(
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"AI.Error.InvalidProviderOutput",
)("InvalidProviderOutput", {
...ReasonFields,
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class UnknownProviderError extends Schema.TaggedError<UnknownProviderError>("AI.Error.UnknownProvider")(
"UnknownProvider",
ReasonFields,
) {}
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"),
message: Schema.String,
status: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export const AIErrorReason = Schema.Union([
InvalidRequestError,
NoRouteError,
AuthenticationError,
RateLimitError,
QuotaExceededError,
ContentPolicyError,
ProviderInternalError,
TransportError,
InvalidProviderOutputError,
UnknownProviderError,
InvalidRequestReason,
NoRouteReason,
AuthenticationReason,
RateLimitReason,
QuotaExceededReason,
ContentPolicyReason,
ProviderInternalReason,
TransportReason,
InvalidProviderOutputReason,
UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag"))
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
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
override get message(): string {
return this.reason.message
override get message() {
return `${this.module}.${this.method}: ${this.reason.message}`
}
}
+9 -7
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
@@ -148,13 +148,15 @@ describe("llm route", () => {
}),
)
test("builds models from configured routes", () => {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
it.effect("builds models from configured routes", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
})
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
}),
)
it.effect("does not register duplicate route ids globally", () =>
Effect.gen(function* () {
+206 -355
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,86 +18,66 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
expect(error.reason).toBeInstanceOf(Error)
expect(error.cause).toBe(error.reason)
return error
}
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
describe("RequestExecutor", () => {
it.effect("preserves externally captured HTTP errors without inventing response context", () =>
Effect.sync(() => {
const cause = new Error("upstream request failed")
const body = '{"error":{"message":"Rate limited","trace":"original"}}'
const error = RequestExecutor.httpFailure({
message: "Rate limited",
url: request.url,
status: 429,
responseHeaders: { "Retry-After": "2", "X-Request-ID": "req_external" },
responseBody: body,
cause,
})
expect(error.message).toBe("Rate limited")
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 2000 })
expect(error.reason.body).toBe(body)
expect(error.reason.cause).toBe(cause)
expect(error.reason.http).toEqual(
new HttpContext({
url: request.url,
status: 429,
headers: { "retry-after": "2", "x-request-id": "req_external" },
}),
)
expect(RequestExecutor.httpFailure({ message: "No response", url: request.url }).reason.http).toBeUndefined()
expect(RequestExecutor.httpFailure({ message: "No URL", status: 500 }).reason.http).toBeUndefined()
}),
)
it.effect("retains the original body-read failure on an HTTP status error", () =>
Effect.gen(function* () {
const cause = new Error("response body disconnected")
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(cause)
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
),
),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.reason.cause).toBe(cause)
expect(error.reason.body).toBeUndefined()
expect(error.reason.http).toMatchObject({ status: 503, headers: { "x-request-id": "req_failed_body" } })
}),
)
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.message).toBe("ECONNRESET: disconnected query-secret-123 header-secret-456")
expect(error.reason.http).toMatchObject({ status: 200, url: secretRequest.url })
expect(error.reason.cause).toMatchObject({ code: "ECONNRESET" })
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
transport: "http",
operation: "read",
code: "ECONNRESET",
@@ -105,14 +85,15 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
@@ -123,23 +104,23 @@ describe("RequestExecutor", () => {
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.message).toBe("ECONNRESET: socket closed")
expect(error.reason.cause).toBeInstanceOf(TypeError)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed",
operation: "read",
code: "ECONNRESET",
})
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
@@ -152,10 +133,8 @@ describe("RequestExecutor", () => {
.pipe(Effect.flip)
expectAIError(error)
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("reports the request sent by middleware", () =>
@@ -173,11 +152,16 @@ describe("RequestExecutor", () => {
.pipe(Effect.flip)
expectAIError(error)
expect(error.message).toBe("ECONNRESET: proxy disconnected proxy-secret")
expect(error.reason.http).toBeUndefined()
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected proxy-secret",
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
headers: { authorization: "Bearer proxy-secret" },
},
},
})
}).pipe(
Effect.provide(
@@ -204,9 +188,11 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
),
),
)
@@ -220,9 +206,9 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
expect(error.reason.http?.status).toBe(413)
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
@@ -234,13 +220,15 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "context-overflow",
http: { response: { status: 413 } },
})
expect(error.reason.http?.status).toBe(413)
}).pipe(
Effect.provide(
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
),
),
)
@@ -253,8 +241,8 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -263,18 +251,20 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toBe(largeProviderMessage)
expect(error.reason.body).toContain(largeProviderMessage)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
}).pipe(
Effect.provide(
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
),
),
)
@@ -287,9 +277,9 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "Provider request failed with HTTP 400",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -301,7 +291,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -318,7 +308,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -331,27 +321,37 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "RateLimit",
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "secret",
expect(error).toMatchObject({
reason: {
_tag: "RateLimit",
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
headers: { authorization: "Bearer secret", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "secret",
},
},
},
},
})
expect(error.reason.body).toBe("rate limited")
expect(errorHttp(error)?.body).toBe("rate limited")
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
),
),
)
@@ -362,9 +362,10 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -384,18 +385,20 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
),
),
)
@@ -406,8 +409,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0,
limit: { requests: "100", "input-tokens": "10000" },
remaining: { requests: "12", "input-tokens": "9000" },
@@ -415,18 +418,20 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
responsesLayer([
new Response("overloaded", {
status: 529,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
),
),
)
@@ -439,20 +444,15 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
),
)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.http?.status).toBe(503)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
@@ -465,14 +465,15 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.http?.status).toBe(status)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe(
Effect.provide(
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
),
)
@@ -483,28 +484,21 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
expect(yield* Ref.get(attempts)).toBe(1)
}),
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
expect(errorHttp(error)?.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
)
it.effect("preserves response body fields", () =>
@@ -513,12 +507,16 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
expect(errorHttp(error)?.body).toBe(
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
)
}).pipe(
Effect.provide(
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
),
),
)
@@ -529,10 +527,12 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectAIError(error)
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
),
),
)
@@ -540,7 +540,9 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -562,170 +564,19 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.reason.body).toBe("not-json")
expect(error.reason.cause).toBeDefined()
expect(error.reason.http).toMatchObject({ status: 200, headers: { "content-type": "text/event-stream" } })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
})
describe("WebSocket channel execution", () => {
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
]
it.effect("preserves close reasons and native event causes without fabricated HTTP metadata", () =>
Effect.gen(function* () {
class TestSocket extends EventTarget {
readyState = globalThis.WebSocket.OPEN
send() {}
close() {}
}
const socket = new TestSocket()
const connection = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
socket as unknown as globalThis.WebSocket,
{ url: "wss://provider.test/responses", headers: Headers.empty },
)
const event = new CloseEvent("close", { code: 1011, reason: "upstream trace: req_close" })
socket.dispatchEvent(event)
const error = yield* connection.messages.pipe(Stream.runDrain, Effect.flip)
expect(error.reason).toMatchObject({ _tag: "Transport", code: "1011", phase: "close" })
expect(error.message).toBe("WebSocket closed with code 1011")
expect(error.reason.body).toBe(event.reason)
expect(error.reason.cause).toBe(event)
expect(error.reason.http).toBeUndefined()
yield* connection.close
}),
)
it.effect("preserves opening event errors and native send exceptions", () =>
Effect.gen(function* () {
const cause = new Error("native send failed")
class TestSocket extends EventTarget {
readyState = globalThis.WebSocket.CONNECTING
send() {
throw cause
}
close() {}
}
const socket = new TestSocket()
const open = WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
socket as unknown as globalThis.WebSocket,
{ url: "wss://provider.test/responses", headers: Headers.empty },
)
const fiber = yield* open.pipe(Effect.flip, Effect.forkChild({ startImmediately: true }))
const event = new ErrorEvent("error", { message: "handshake rejected", error: cause })
socket.dispatchEvent(event)
const error = yield* Fiber.join(fiber)
expect(error.reason.cause).toBe(cause)
expect(error.message).toContain("handshake rejected")
expect(error.reason.http).toBeUndefined()
socket.readyState = globalThis.WebSocket.OPEN
const connection = yield* open
const sent = yield* connection.sendText("create").pipe(Effect.flip)
expect(sent.reason.cause).toBe(cause)
expect(sent.message).toBe(cause.message)
yield* connection.close
}),
)
it.effect("preserves raw driver failures and known upgrade metadata", () =>
Effect.gen(function* () {
const cause = new Error("driver validation failed")
const frame = '{ "error": "failed", "trace": "original" }'
const http = new HttpContext({
url: "https://provider.test/responses",
status: 101,
headers: { upgrade: "websocket" },
})
const executor = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
http,
sendText: () => Effect.void,
messages: Stream.make(frame),
close: Effect.void,
}),
})
const execution = yield* executor.execute({
id: "exchange_error",
connect: { url: "wss://provider.test/responses", headers: Headers.empty },
fallback: () => Stream.empty,
driver: {
create: () => Effect.succeed({ message: "create", mode: "full" }),
observe: () =>
Effect.succeed({
type: "provider-failure",
error: new AIError({
reason: new InvalidProviderOutputError({
message: "Driver failed",
cause,
body: "narrowed",
}),
}),
}),
},
})
const error = yield* execution.frames.pipe(Stream.runDrain, Effect.flip)
expect(error.message).toBe("Driver failed")
expect(error.reason.body).toBe(frame)
expect(error.reason.cause).toBe(cause)
expect(error.reason.http).toBe(http)
expect(execution.http).toBe(http)
}),
)
it.effect("retains diagnostic fields when annotating transport delivery", () =>
Effect.gen(function* () {
const cause = new Error("connection closed")
const executor = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fail(
new AIError({
reason: new TransportError({
message: "Socket closed",
transport: "websocket",
operation: "read",
phase: "close",
recovery: "retry-full",
body: "server close detail",
cause,
}),
}),
),
close: Effect.void,
}),
})
const execution = yield* executor.execute({
id: "exchange_closed",
connect: { url: "wss://provider.test/responses", headers: Headers.empty },
fallback: () => Stream.empty,
driver: {
create: () => Effect.succeed({ message: "create", mode: "full" }),
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
},
})
const error = yield* execution.frames.pipe(Stream.runDrain, Effect.flip)
expect(error.message).toBe("Socket closed")
expect(error.reason.body).toBe("server close detail")
expect(error.reason.cause).toBe(cause)
expect(error.reason).toMatchObject({ phase: "close", delivery: "ambiguous", recovery: "retry-full" })
expect(error.reason.http).toBeUndefined()
}),
)
it.effect("runs a channel driver through the direct executor", () =>
Effect.gen(function* () {
const sent = yield* Ref.make("")
+15 -17
View File
@@ -44,23 +44,21 @@ describe("Tool.make (dynamic JSON Schema)", () => {
expect(definition?.inputSchema).toEqual(jsonSchema)
})
it.effect("execute receives the raw input untouched", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = yield* tool.execute({ hello: "world" })
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
}),
)
test("execute receives the raw input untouched", async () => {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = await Effect.runPromise(tool.execute({ hello: "world" }))
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
})
})
describe("LLM.generateObject", () => {
+16 -42
View File
@@ -7,37 +7,6 @@ import { it } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
describe("Image", () => {
for (const provider of [OpenAI, Google, XAI, ZAI]) {
const model = provider.configure({ apiKey: "test", baseURL: "https://image.test" }).image("image-model")
for (const body of ['{"data":42,"candidates":42,"opaque":{"nested":[1,2]},"trace":"outer"}', '{"invalid":']) {
it.effect(`retains ${model.provider} image response body and decode cause: ${body}`, () =>
Effect.gen(function* () {
const error = yield* Image.generate({ model, prompt: "hello" }).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("invalid response")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-image-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://image.test/")
expect(error.reason.cause).toBeInstanceOf(Error)
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(body, {
headers: { "content-type": "application/json", "x-image-trace": "trace-1" },
}),
),
),
),
),
),
),
)
}
}
it.effect("generates images through the OpenAI Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
@@ -561,17 +530,22 @@ describe("Image", () => {
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(error.message).toContain("finish reasons: IMAGE_SAFETY")
expect(JSON.parse(error.reason.body ?? "")).toEqual({
promptFeedback: { blockReason: "SAFETY" },
candidates: [
{
finishReason: "IMAGE_SAFETY",
finishMessage: "The generated image was blocked by safety filters.",
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
content: { parts: [{ text: "blocked", thought: false }] },
},
],
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
expect(error.reason.providerMetadata).toEqual({
google: {
promptFeedback: { blockReason: "SAFETY" },
candidates: [
{
index: 0,
finishReason: "IMAGE_SAFETY",
finishMessage: "The generated image was blocked by safety filters.",
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
citationMetadata: undefined,
groundingMetadata: undefined,
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
},
],
},
})
}),
),
-33
View File
@@ -111,39 +111,6 @@ describe("provider error classification", () => {
})
describe("provider error rawBody classification", () => {
test("classifies provider envelopes without separate code inputs", () => {
const cases = [
['{"type":"error","error":{"type":"overloaded_error","message":"Try again"}}', "ProviderInternal"],
['{"error":{"code":"insufficient_quota","message":"Request failed"}}', "QuotaExceeded"],
[
'{"type":"response.failed","response":{"error":{"code":"authentication_error","message":"Denied"}}}',
"Authentication",
],
['{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","message":"Try again"}}', "ProviderInternal"],
['{"exception":{"type":"throttlingException","details":{"message":"Try again"}}}', "RateLimit"],
] as const
for (const [rawBody, expected] of cases) {
const reason = classifyProviderFailure({ message: "Request failed", rawBody })
expect(reason._tag).toBe(expected)
expect(reason.body).toBe(rawBody)
expect(reason).not.toHaveProperty("code")
}
})
test("classifies separately supplied SDK data without replacing the response body", () => {
const data = { error: { code: "authentication_error" } }
for (const value of [data, JSON.stringify(data)]) {
const reason = classifyProviderFailure({
message: "Request failed",
status: 400,
rawBody: '{"message":"Request failed"}',
data: value,
})
expect(reason._tag).toBe("Authentication")
expect(reason.body).toBe('{"message":"Request failed"}')
}
})
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("`tool_use` ids were found without `tool_result` blocks")
expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
}),
)
})
@@ -966,8 +966,9 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
})
}),
@@ -1056,8 +1057,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
@@ -1078,8 +1079,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
@@ -1105,8 +1106,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
@@ -1121,8 +1122,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
@@ -1644,7 +1645,7 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "overloaded_error: Overloaded" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error: Overloaded" })
}),
)
@@ -1662,9 +1663,10 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "invalid_request_error: prompt is too long: 210000 tokens",
classification: "context-overflow",
})
}),
)
@@ -1676,7 +1678,7 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "overloaded_error" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error" })
}),
)
@@ -1687,7 +1689,7 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "Anthropic Messages stream error" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Anthropic Messages stream error" })
}),
)
@@ -1704,7 +1706,7 @@ describe("Anthropic Messages route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
}),
)
@@ -531,104 +531,6 @@ describe("Bedrock Converse route", () => {
}),
)
for (const signature of [undefined, "", " "]) {
for (const cache of ["none", "auto"] as const) {
it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{
type: "reasoning",
text: "Partial thought",
providerMetadata: signature === undefined ? undefined : { bedrock: { signature } },
cache: new CacheHint({ type: "ephemeral" }),
},
]),
Message.user("Continue"),
],
cache,
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "assistant",
content: [{ text: "Partial thought" }, { cachePoint: { type: "default" } }],
})
}),
)
}
}
it.effect("omits empty unsigned reasoning without leaving an empty or cache-only assistant", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{ type: "reasoning", text: "", cache },
{ type: "reasoning", text: " ", providerMetadata: { bedrock: { signature: "" } }, cache },
]),
Message.user([{ type: "text", text: "Continue", cache }]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ text: "Think" }, { text: "Continue" }, { cachePoint: { type: "default" } }],
},
])
}),
)
it.effect("demotes foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "Foreign thought", providerMetadata: { anthropic: { signature: "old" } } },
{
type: "reasoning",
text: "Signed thought",
providerMetadata: { bedrock: { signature: "sig_1" } },
cache,
},
{ type: "reasoning", text: "", encrypted: "sig_2", cache },
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "cmVkYWN0ZWQ=" } }, cache },
{ type: "text", text: "Checking" },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ text: "Foreign thought" },
{ reasoningContent: { reasoningText: { text: "Signed thought", signature: "sig_1" } } },
{ reasoningContent: { reasoningText: { text: "", signature: "sig_2" } } },
{ reasoningContent: { redactedContent: "cmVkYWN0ZWQ=" } },
{ text: "Checking" },
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
],
},
])
}),
)
it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -887,32 +789,19 @@ describe("Bedrock Converse route", () => {
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "A future provider failure" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
}),
)
it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () {
const payload = { message: "Slow down", details: { opaque: [1, 2] }, trace: "outer", p: "padding" }
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
exceptionFrame("throttlingException", payload),
exceptionFrame("throttlingException", { message: "Slow down" }),
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "RateLimit" }, message: "Slow down" })
expect(JSON.parse(error.reason.body ?? "")).toEqual({
headers: {
":message-type": { type: "string", value: "exception" },
":exception-type": { type: "string", value: "throttlingException" },
":content-type": { type: "string", value: "application/json" },
},
body: JSON.stringify(payload),
})
expect(error.reason.http).toMatchObject({
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
})
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
}),
)
@@ -925,9 +814,10 @@ describe("Bedrock Converse route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "Input is too long for requested model",
classification: "context-overflow",
})
}),
)
@@ -946,7 +836,7 @@ describe("Bedrock Converse route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "Upstream model failed" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
}),
)
@@ -957,32 +847,10 @@ describe("Bedrock Converse route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "BadStream: Stream failed",
})
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
headers: { ":error-code": { value: "BadStream" } },
body: "",
})
}),
)
it.effect("retains malformed AWS payloads with headers and decode cause", () =>
Effect.gen(function* () {
const headers = {
":message-type": { type: "string" as const, value: "event" },
":event-type": { type: "string" as const, value: "messageStart" },
}
const body = '{"malformed":'
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(codec.encode({ headers, body: utf8Encoder.encode(body) }))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(JSON.parse(error.reason.body ?? "")).toEqual({ headers, body })
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http?.status).toBe(200)
}),
)
+12 -10
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
@@ -136,15 +136,17 @@ describe("Cloudflare", () => {
}),
)
test("defaults AI Gateway id to default when omitted or blank", () => {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
})
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
}),
)
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
Effect.gen(function* () {
@@ -1,102 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src/index.js"
import { Anthropic, Google, OpenAI } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("provider error retention", () => {
const options = { apiKey: "test", baseURL: "https://provider.test" }
const cases = [
{
name: "Anthropic",
model: Anthropic.configure(options).model("claude"),
event: { type: "error", error: { type: "rate_limit_error", message: "Slow down", details: { opaque: [1, 2] } } },
},
{
name: "OpenAI Chat",
model: OpenAI.configure(options).chat("gpt"),
event: { error: { code: "rate_limit_exceeded", message: "Slow down", details: { opaque: [1, 2] } } },
},
{
name: "OpenAI Responses",
model: OpenAI.configure(options).responses("gpt"),
event: {
type: "response.failed",
response: {
id: "resp_error",
error: { code: "rate_limit_exceeded", message: "Slow down", details: { opaque: [1, 2] } },
opaque: { upstream: true },
},
},
},
{
name: "Gemini",
model: Google.configure(options).model("gemini"),
event: { error: { code: 429, status: "RESOURCE_EXHAUSTED", message: "Slow down", details: { opaque: [1, 2] } } },
},
]
for (const entry of cases) {
it.effect(`retains the complete ${entry.name} event and HTTP context`, () =>
Effect.gen(function* () {
const body = JSON.stringify({ ...entry.event, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(LLM.request({ model: entry.model, prompt: "hello" })).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toContain("Slow down")
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
expect(error.reason.cause).toBeUndefined()
expect(error.cause).toBe(error.reason)
}),
)
}
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
const error = yield* LLMClient.generate(
LLM.request({ model: Anthropic.configure(options).model("claude"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("retains the HTTP response context when a channel falls back", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"code":"rate_limit_exceeded","message":"Slow down","extra":42}}'
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure(options).responses("gpt"), prompt: "hello" }),
{
webSocket: {
execute: (exchange) => Effect.succeed({ frames: exchange.fallback(), complete: Effect.void }),
},
},
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "fallback-1" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({
url: "https://provider.test/responses",
status: 200,
headers: { "x-provider-trace": "fallback-1" },
})
}),
)
})
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
@@ -376,9 +376,11 @@ describe("Google Vertex providers", () => {
}),
)
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
})
it.effect("rejects tuned Gemini models in express mode", () =>
Effect.sync(() => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
}),
)
})
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
@@ -31,71 +31,82 @@ import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
test("assigns provider-owned metadata namespaces across native routes", () => {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"), "custom"],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
})
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
test("preserves native Together AI and Cerebras provider and route identities", () => {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
})
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
test("preserves native DeepInfra provider and route identity", () => {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
})
it.effect("preserves native DeepInfra provider and route identity", () =>
Effect.gen(function* () {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
}),
)
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
@@ -150,35 +161,39 @@ describe("native OpenAI-compatible providers", () => {
}),
)
test("normalizes DeepInfra API roots without duplicating the OpenAI path", () => {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
Effect.gen(function* () {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
)
}
})
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
)
}
}),
)
test("maps package settings onto native executable models", () => {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
@@ -1457,7 +1457,7 @@ describe("OpenAI Chat route", () => {
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
choices: [{ finish_reason: "tool_calls" }],
})
}),
@@ -1534,9 +1534,9 @@ describe("OpenAI Chat route", () => {
)
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
expect(error.message).toBe("ECONNRESET: socket closed unexpectedly")
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
@@ -1552,9 +1552,9 @@ describe("OpenAI Chat route", () => {
Effect.flip,
)
expect(error.message).toBe("ECONNRESET: socket closed before output")
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed before output",
transport: "http",
operation: "read",
code: "ECONNRESET",
@@ -1575,7 +1575,7 @@ describe("OpenAI Chat route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
}),
)
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
@@ -91,38 +91,40 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
test("provides model helpers for compatible provider families", () => {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
@@ -487,8 +489,9 @@ describe("OpenAI-compatible Chat route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
message: "OpenAI Chat stream ended without finish_reason",
})
}),
@@ -524,11 +527,11 @@ describe("OpenAI-compatible Chat route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "ProviderInternal" },
expect(error.reason).toMatchObject({
_tag: "ProviderInternal",
message: "Provider reported a network error (finish_reason: network_error)",
})
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
expect(decodeJson(error.body ?? "")).toMatchObject({
id: "chatcmpl_fixture",
choices: [{ finish_reason: "network_error" }],
})
@@ -537,8 +540,8 @@ describe("OpenAI-compatible Chat route", () => {
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "error")))),
Effect.flip,
)
expect(generic).toMatchObject({
reason: { _tag: "UnknownProvider" },
expect(generic.reason).toMatchObject({
_tag: "UnknownProvider",
message: "Provider reported an error (finish_reason: error)",
})
}),
@@ -559,8 +562,8 @@ describe("OpenAI-compatible Chat route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "Provider disconnected" })
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Provider disconnected", status: 502 })
expect(decodeJson(error.body ?? "")).toMatchObject({
id: "chatcmpl_error",
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
trace_id: "trace_1",
@@ -600,7 +603,7 @@ describe("OpenAI-compatible Chat route", () => {
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
choices: [{ delta: { tool_calls: [{ id: "call_1" }] } }],
})
}),
@@ -4,7 +4,6 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
AIError,
HttpContext,
HttpOptions,
LLMEvent,
LLMRequest,
@@ -13,7 +12,7 @@ import {
ToolCallPart,
ToolDefinition,
ToolResultPart,
TransportError,
TransportReason,
Usage,
} from "../../src/index.js"
import {
@@ -53,11 +52,10 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
return Effect.succeed({
type: "provider-failure",
error: new AIError({
reason: new TransportError({
module: "test",
method: "stream",
reason: new TransportReason({
message: "provider rejected request",
body: frame,
cause: new Error("provider cause"),
http: new HttpContext({ url: "https://provider.test", status: 200, headers: { "x-trace": "trace-1" } }),
transport: "websocket",
operation: "read",
phase: "receive",
@@ -746,18 +744,6 @@ describe("OpenAI Responses route", () => {
reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
},
})
for (const observation of [missing, limit]) {
expect(observation.type).toBe("rejected")
if (observation.type !== "rejected") continue
expect(observation.error.message).toBe("provider rejected request")
expect(observation.error.reason.cause).toBeInstanceOf(Error)
expect(observation.error.reason.cause).toMatchObject({ message: "provider cause" })
expect(observation.error.reason.http).toMatchObject({ status: 200, headers: { "x-trace": "trace-1" } })
expect(ProviderShared.decodeJson(observation.error.reason.body ?? "")).toMatchObject({
type: "error",
error: { code: expect.any(String) },
})
}
}),
)
@@ -1124,7 +1110,9 @@ describe("OpenAI Responses route", () => {
it.effect("marks post-send WebSocket failures with delivery state", () =>
Effect.gen(function* () {
const failure = new AIError({
reason: new TransportError({
module: "test",
method: "receive",
reason: new TransportReason({
message: "socket closed",
transport: "websocket",
operation: "read",
@@ -4447,7 +4435,7 @@ describe("OpenAI Responses route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error).toMatchObject({ reason: { _tag: "RateLimit" }, message: "rate_limit_exceeded: Slow down" })
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" })
}),
)
@@ -4458,7 +4446,7 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "internal_error" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
}),
)
@@ -4469,7 +4457,7 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "internal_error" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
}),
)
@@ -4494,8 +4482,8 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "ProviderInternal" },
expect(error.reason).toMatchObject({
_tag: "ProviderInternal",
message: "server_error: Upstream model unavailable",
})
}),
@@ -4515,7 +4503,7 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "invalid_prompt" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "invalid_prompt" })
}),
)
@@ -4538,9 +4526,10 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
})
}),
)
@@ -4565,9 +4554,10 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
})
}),
)
@@ -4589,7 +4579,7 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "Something went wrong" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
}),
)
@@ -4601,8 +4591,8 @@ describe("OpenAI Responses route", () => {
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.message).toContain('"error":null')
expect(error.reason.body).toBe(error.message)
expect(error.reason.message).toContain('"error":null')
expect(error.body).toBe(error.reason.message)
}),
)
@@ -4614,8 +4604,8 @@ describe("OpenAI Responses route", () => {
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.message).toContain('"type":"error"')
expect(error.reason.body).toBe(error.message)
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
}),
)
@@ -4627,8 +4617,8 @@ describe("OpenAI Responses route", () => {
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.message).toContain('"resp_failed_3"')
expect(error.reason.body).toBe(error.message)
expect(error.reason.message).toContain('"resp_failed_3"')
expect(error.body).toBe(error.reason.message)
}),
)
@@ -4645,7 +4635,7 @@ describe("OpenAI Responses route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
}),
)
})
+1 -179
View File
@@ -1,12 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { AIError, HttpContext, InvalidProviderOutputError, LLM } from "../src/index.js"
import { Anthropic } from "../src/providers.js"
import { Auth, Framing, HttpTransport, LLMClient, Route } from "../src/route.js"
import { fixedResponse, truncatedStream } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
import { it } from "./lib/effect.js"
import { Auth } from "../src/route.js"
describe("Route.with", () => {
test("merges endpoint query and header defaults while replacing auth and id", () => {
@@ -56,175 +50,3 @@ describe("Route.with", () => {
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
})
})
describe("Route diagnostics", () => {
const route = OpenAIChat.route.with({ endpoint: { baseURL: "https://provider.test/v1" } })
const request = LLM.request({ model: route.model({ id: "test" }), prompt: "Hello" })
const headers = { "content-type": "text/event-stream", "x-request-id": "req_stream" }
it.effect("retains an entire invalid event and its validation cause", () =>
Effect.gen(function* () {
const frame = '{ "choices": "invalid", "diagnostic": { "detail": "original" } }'
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(frame), { headers })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(frame)
expect(error.reason.cause).toMatchObject({ _tag: "SchemaError" })
expect(error.reason.http).toEqual(
new HttpContext({ url: "https://provider.test/v1/chat/completions", status: 200, headers }),
)
}),
)
it.effect("retains original provider error fields discarded by the event schema", () =>
Effect.gen(function* () {
const frame =
'{ "error": { "message": "Rate limit exceeded", "code": "rate_limit_exceeded", "debug": { "trace": "original" } }, "request_id": "req_original" }'
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(frame), { headers })),
Effect.flip,
)
expect(error.reason._tag).toBe("RateLimit")
expect(error.message).toBe("Rate limit exceeded")
expect(error.reason.body).toBe(frame)
expect(error.reason.http?.headers["x-request-id"]).toBe("req_stream")
}),
)
it.effect("preserves semantic parser bodies while replacing serialized event fallbacks", () =>
Effect.forEach([undefined, '{"query": BROKEN}', ""], (body) =>
Effect.gen(function* () {
const cause = new Error("parser failure")
const http = new HttpContext({
url: "https://upstream.test/v1",
status: 202,
headers: { "x-trace": "precise" },
})
const frame = '{ "type": "failure", "debug": "not in schema" }'
const failing = Route.make({
id: "diagnostics",
provider: "test",
endpoint: route.endpoint,
framing: Framing.sse,
protocol: {
...OpenAIChat.protocol,
stream: {
event: Schema.fromJsonString(Schema.Struct({ type: Schema.String })),
initial: () => undefined,
step: (_state, event) =>
Effect.fail(
new AIError({
reason: new InvalidProviderOutputError({
message: "Parser failed",
body: body ?? JSON.stringify(event),
http,
cause,
}),
}),
),
},
},
})
const error = yield* LLMClient.generate(
LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(frame), { headers })), Effect.flip)
expect(error.message).toBe("Parser failed")
expect(error.reason.body).toBe(body ?? frame)
expect(error.reason.cause).toBe(cause)
expect(error.reason.http).toBe(http)
}),
),
)
it.effect("retains malformed assembled Anthropic hosted-tool arguments", () =>
Effect.gen(function* () {
const body = '{"query": BROKEN}'
const error = yield* LLMClient.generate(
LLM.request({
model: Anthropic.configure({ apiKey: "test", baseURL: "https://provider.test" }).model("claude"),
prompt: "Hello",
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id: "srv1", name: "web_search" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query": ' },
},
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "BROKEN}" } },
{ type: "content_block_stop", index: 0 },
),
{ headers },
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
expect(error.reason.body).toBe(body)
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toMatchObject({ status: 200, headers })
}),
)
it.effect("adds successful HTTP metadata without replacing framing diagnostics", () =>
Effect.gen(function* () {
const cause = new Error("frame checksum mismatch")
const failure = new AIError({
reason: new InvalidProviderOutputError({
message: "Invalid frame",
body: "original frame representation",
cause,
}),
})
const failing = route.with({
transport: HttpTransport.httpJson({ framing: { id: "failure", frame: () => Stream.fail(failure) } }),
})
const error = yield* LLMClient.generate(
LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }),
).pipe(Effect.provide(fixedResponse("wire bytes", { headers })), Effect.flip)
expect(error.reason.body).toBe(failure.reason.body)
expect(error.reason.cause).toBe(cause)
expect(error.message).toBe(failure.message)
expect(error.reason.http?.status).toBe(200)
expect(error.reason.http?.headers).toEqual(headers)
}),
)
it.effect("retains the original read error after successful response headers", () =>
Effect.gen(function* () {
const cause = new Error("socket disconnected")
const error = yield* LLMClient.generate(request).pipe(Effect.provide(truncatedStream([], cause)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read" })
expect(error.reason.cause).toBe(cause)
expect(error.reason.http?.status).toBe(200)
expect(error.reason.body).toBeUndefined()
}),
)
it.effect("retains successful response headers on an incomplete stream", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse("", { headers })), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.reason.http?.headers).toEqual(headers)
expect(error.reason.body).toBeUndefined()
}),
)
})
+44 -218
View File
@@ -4,29 +4,17 @@ import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
AIError,
AIErrorReason,
AuthenticationError,
ContentPart,
ContentPolicyError,
HttpContext,
InvalidProviderOutputError,
InvalidRequestError,
InvalidRequestReason,
LLMEvent,
LLMRequest,
LanguageModel,
ModelID,
NoRouteError,
ProviderID,
ProviderInternalError,
QuotaExceededError,
RateLimitError,
RouteID,
TransportError,
UnknownProviderError,
TransportReason,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
import { it } from "./lib/effect.js"
const model = new LanguageModel({
id: ModelID.make("fake-model"),
@@ -102,48 +90,49 @@ describe("AI.Usage", () => {
expect(ProviderShared.sumTokens()).toBeUndefined()
})
it.effect("sseFraming maps decoder failures to AI errors", () =>
Effect.gen(function* () {
const error = yield* ProviderShared.sseFraming(
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
).pipe(Stream.runCollect, Effect.flip)
test("sseFraming maps decoder failures to AI errors", async () => {
const error = await Effect.runPromise(
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
Stream.runCollect,
Effect.flip,
),
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
it.effect("sseFraming ignores retry directives without ending the stream", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect)
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
}),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
it.effect("sseFraming preserves event data around retry directives", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect)
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
}),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
@@ -153,22 +142,21 @@ describe("AI.Usage", () => {
})
})
it.effect("AI errors expose the shared runtime tag", () =>
Effect.gen(function* () {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(yield* Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))).toBe("caught")
}),
)
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
module: "test",
method: "call",
reason: new InvalidRequestReason({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
test("transport errors serialize execution facts", () => {
const reason = new TransportError({
message: "Connection closed",
const reason = new TransportReason({
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
@@ -176,176 +164,14 @@ test("transport errors serialize execution facts", () => {
recovery: "fail",
})
expect(Schema.encodeSync(TransportError)(reason)).toEqual({
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
_tag: "Transport",
message: "Connection closed",
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.decodeUnknownSync(TransportError)(Schema.encodeSync(TransportError)(reason))).toEqual(reason)
})
test("AI errors serialize diagnostics only on their typed reason", () => {
const cause = new SyntaxError("Unexpected end of JSON input")
const error = new AIError({
reason: new InvalidRequestError({
message: "Invalid provider response",
body: '{"error":',
http: new HttpContext({
url: "https://provider.test/v1/messages",
status: 400,
headers: { "request-id": "req_123" },
}),
cause,
parameter: "messages",
classification: "context-overflow",
}),
})
const encoded = Schema.encodeSync(AIError)(error)
expect(encoded).toEqual({
_tag: "AI.Error",
reason: {
_tag: "InvalidRequest",
message: "Invalid provider response",
body: '{"error":',
http: {
url: "https://provider.test/v1/messages",
status: 400,
headers: { "request-id": "req_123" },
},
cause: { name: "SyntaxError", message: cause.message, stack: cause.stack },
parameter: "messages",
classification: "context-overflow",
},
})
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(AIError))(
Schema.encodeSync(Schema.fromJsonString(AIError))(error),
)
expect(error).not.toHaveProperty("body")
expect(error).not.toHaveProperty("http")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBe(cause)
expect(decoded).toBeInstanceOf(AIError)
expect(decoded.reason).toBeInstanceOf(InvalidRequestError)
expect(decoded.message).toBe("Invalid provider response")
expect(decoded.reason.message).toBe(decoded.message)
expect(decoded.reason.body).toBe('{"error":')
expect(decoded.reason.http).toEqual(error.reason.http)
expect(decoded.cause).toBe(decoded.reason)
expect(decoded.reason.cause).toBeInstanceOf(Error)
expect(decoded.reason.cause).toMatchObject({ name: "SyntaxError", message: cause.message, stack: cause.stack })
expect(decoded.reason).toMatchObject({ parameter: "messages", classification: "context-overflow" })
})
test("AI error reasons are tagged Errors with required messages", () => {
const reasons = [
new InvalidRequestError({ message: "Invalid request" }),
new NoRouteError({
message: "No route",
route: RouteID.make("missing"),
provider: model.provider,
model: model.id,
}),
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
new RateLimitError({ message: "Rate limited" }),
new QuotaExceededError({ message: "Quota exceeded" }),
new ContentPolicyError({ message: "Content blocked" }),
new ProviderInternalError({ message: "Provider failed" }),
new TransportError({ message: "Connection failed", transport: "http", operation: "request" }),
new InvalidProviderOutputError({ message: "Invalid output" }),
new UnknownProviderError({ message: "Unknown failure" }),
]
expect(reasons.map((reason) => reason._tag)).toEqual([
"InvalidRequest",
"NoRoute",
"Authentication",
"RateLimit",
"QuotaExceeded",
"ContentPolicy",
"ProviderInternal",
"Transport",
"InvalidProviderOutput",
"UnknownProvider",
])
reasons.forEach((reason) => {
expect(reason).toBeInstanceOf(Error)
const encoded = Schema.encodeSync(AIErrorReason)(reason)
const decoded = Schema.decodeUnknownSync(AIErrorReason)(encoded)
expect(decoded).toBeInstanceOf(reason.constructor)
expect(decoded.message).toBe(reason.message)
expect(Schema.decodeUnknownOption(AIErrorReason)({ ...encoded, message: undefined })._tag).toBe("None")
})
})
test("AI error reason enrichment preserves non-enumerable diagnostics", () => {
const cause = new Error("socket disconnected")
const reason = new TransportError({
message: "Connection closed",
body: "close frame detail",
http: new HttpContext({ url: "https://provider.test/responses", status: 101, headers: { upgrade: "websocket" } }),
cause,
transport: "websocket",
operation: "read",
phase: "close",
})
expect(Object.prototype.propertyIsEnumerable.call(reason, "message")).toBe(false)
expect(Object.prototype.propertyIsEnumerable.call(reason, "cause")).toBe(false)
const enriched = AIErrorReason.make({
// oxlint-disable-next-line typescript-eslint/no-misused-spread -- Copy fields rather than iterating the yieldable error.
...reason,
message: reason.message,
cause: reason.cause,
delivery: "ambiguous",
recovery: "retry-full",
})
const error = new AIError({ reason: enriched })
expect(enriched).toBeInstanceOf(TransportError)
expect(error.message).toBe(reason.message)
expect(error.cause).toBe(enriched)
expect(enriched.cause).toBe(cause)
expect(enriched.body).toBe(reason.body)
expect(enriched.http).toBe(reason.http)
expect(enriched).toMatchObject({ phase: "close", delivery: "ambiguous", recovery: "retry-full" })
})
test("AI errors support reason-specific handlers", async () => {
const limited = new AIError({ reason: new RateLimitError({ message: "Slow down", retryAfterMs: 2000 }) })
const invalid = new AIError({ reason: new InvalidRequestError({ message: "Invalid request", parameter: "model" }) })
expect(
await Effect.runPromise(
Effect.fail(limited).pipe(
Effect.catchReason("AI.Error", "RateLimit", (reason) => {
expect(reason).toBe(limited.reason)
expect(reason).toBeInstanceOf(RateLimitError)
return Effect.succeed(reason.retryAfterMs)
}),
),
),
).toBe(2000)
expect(
await Effect.runPromise(
Effect.forEach([limited, invalid], (error) =>
Effect.fail(error).pipe(
Effect.catchReasons("AI.Error", {
RateLimit: (reason) => Effect.succeed(reason.message),
InvalidRequest: (reason) => Effect.succeed(reason.parameter),
}),
),
),
),
).toEqual(["Slow down", "model"])
})
test("HTTP error context requires an observed response", () => {
const decode = Schema.decodeUnknownOption(HttpContext)
expect(decode({ status: 400, headers: {} })._tag).toBe("None")
expect(decode({ url: "https://provider.test", headers: {} })._tag).toBe("None")
expect(decode({ url: "https://provider.test", status: 400 })._tag).toBe("None")
expect(decode({ url: "https://provider.test", status: 0, headers: {} })._tag).toBe("None")
expect(decode({ url: "https://provider.test", status: Number.NaN, headers: {} })._tag).toBe("None")
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
})
+73 -67
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
@@ -277,61 +277,65 @@ describe("LLMClient tools", () => {
}),
)
test("models canonical tool files with URIs", () => {
const decode = Schema.decodeUnknownSync(Content)
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
})
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
}),
)
test("preserves canonical tool file URIs", () => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
it.effect("preserves canonical tool file URIs", () =>
Effect.sync(() => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
})
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
}),
)
it.effect("settles projected URL files as canonical tool results", () =>
Effect.gen(function* () {
@@ -360,22 +364,24 @@ describe("LLMClient tools", () => {
}),
)
test("derives typed output schemas and preserves dynamic output schemas", () => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
Effect.sync(() => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
}),
)
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
+43 -37
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { AIError } from "../src/schema/index.js"
import { ToolStream } from "../src/protocols/utils/tool-stream.js"
@@ -38,40 +38,44 @@ describe("ToolStream", () => {
}),
)
test("exposes cumulative partial string values", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) throw result
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
})
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) throw result
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
})
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
@@ -100,12 +104,14 @@ describe("ToolStream", () => {
}),
)
test("fails appendExisting when the provider skipped the tool start", () => {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
})
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
}),
)
it.effect("uses final input override without losing accumulated deltas", () =>
Effect.gen(function* () {
-9
View File
@@ -79,15 +79,6 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
```
## Retained renderer memory
Run the catalog workload against the production app bundle:
@@ -126,19 +126,13 @@ test("keeps moving upward while drag-selecting above the timeline", async ({ pag
)
})
})
const textBox = await text.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null
})
const textBox = await text.boundingBox()
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
// Start on a text line, not the empty right edge or gap between wrapped lines.
await page.mouse.move(textBox.x + Math.min(20, textBox.width / 2), textBox.y + textBox.height / 2)
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
@@ -201,45 +195,6 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("keeps an older answer selected while scrolling within the interaction buffer", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const answer = page.getByText("History 78.", { exact: false })
await expect(answer).toBeVisible()
await expect
.poll(() =>
answer.evaluate((element) => element.closest('[data-component="markdown"]')?.hasAttribute("data-markdown-ready")),
)
.toBe(true)
const textBox = await answer.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
})
const scrollBox = await scroller.boundingBox()
expect(scrollBox).not.toBeNull()
if (!scrollBox) return
await page.mouse.move(textBox.x + Math.min(180, textBox.width - 2), textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 2, textBox.y + textBox.height / 2, { steps: 30 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.mouse.move(scrollBox.x + scrollBox.width / 2, scrollBox.y + scrollBox.height / 2)
await page.mouse.wheel(0, -450)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(400)
await expect(answer).toHaveCount(1)
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.getByRole("heading", { name: "Timeline visual stability" }).click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed)).toBe(true)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -304,16 +259,12 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
const before = await scroller.evaluate((element) => element.scrollTop)
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
await nested.press("PageUp")
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await page.waitForTimeout(300)
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await nested.evaluate((element) => element.scrollTo({ top: 0, behavior: "instant" }))
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBe(0)
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 }))
element.scrollTo({ top: Math.min(300, element.scrollHeight - element.clientHeight), behavior: "instant" })
})
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(500)
await nested.evaluate((element) => (element.scrollTop = 0))
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
expect(boundaryBefore).toBeGreaterThan(0)
await nested.press("PageUp")
@@ -11,73 +11,115 @@ import {
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
const scenarios = [
{ cached: false, review: false, resized: false },
{ cached: false, review: true, resized: false },
{ cached: true, review: false, resized: false },
{ cached: true, review: true, resized: false },
{ cached: true, review: true, resized: true },
]
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
scenarios.forEach((scenario) => {
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
benchmark(
"benchmarks session tab switching with and without the review pane",
async ({ browser, report }, testInfo) => {
benchmark.setTimeout(360_000)
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
const results = {
closed: { cold: [] as Result[], hot: [] as Result[] },
open: { cold: [] as Result[], hot: [] as Result[] },
}
for (const reviewPane of ["closed", "open"] as const) {
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < runs; run++) {
results[reviewPane][mode].push(
await withBenchmarkPage(
browser,
`session-tab-switch-${reviewPane}-${mode}-${run}`,
(page) => trial(page, mode, reviewPane),
testInfo,
),
)
}
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
}
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
},
)
return measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1),
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
},
testInfo,
)
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "open") {
const reviewDiffs = createReviewDiffs()
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
await installTimelineSettings(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (reviewPane === "open") {
await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
},
]),
)
}
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
return Object.fromEntries(
Object.entries(results).map(([reviewPane, values]) => [
reviewPane,
summarize(values as Record<"cold" | "hot", Result[]>),
]),
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
await expect(tab).toHaveCount(1)
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
async function openReviewPane(page: Page) {
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toBeVisible()
const panel = page.locator("#review-panel")
await expect(panel).toBeVisible()
await page.waitForFunction(() => {
const text = document.querySelector("#review-panel")?.textContent ?? ""
const panel = document.querySelector<HTMLElement>("#review-panel")
const text = panel?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
}
@@ -20,10 +20,9 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
samples,
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: stable < 0 ? null : samples[stable + 2].observedAtMs,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
@@ -1,67 +0,0 @@
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
},
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
@@ -25,7 +25,7 @@ async function installSessionSwitchProbe(
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
@@ -37,6 +37,7 @@ async function installSessionSwitchProbe(
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
@@ -62,30 +63,26 @@ async function installSessionSwitchProbe(
)
if (root) {
const view = root.getBoundingClientRect()
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
observedAtMs,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
@@ -97,7 +94,7 @@ async function installSessionSwitchProbe(
})
} else {
samples.push({
observedAtMs: performance.now() - started,
observedAtMs,
destination: [],
source: [],
hasVisibleRows: false,
@@ -110,25 +107,23 @@ async function installSessionSwitchProbe(
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
@@ -53,15 +53,6 @@ test("reports missing correctness without throwing", () => {
expect(result.stableObservedMs).toBeNull()
})
test("does not report stability for only two correct samples", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(16)
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
@@ -1,120 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_large_paste"
const directory = "/repo/large-paste"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_large_paste",
worktree: directory,
vcs: "git",
name: "large-paste",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
const input = page.locator('[data-component="composer-editor"]')
await expectAppVisible(input)
await expect(input).toBeEditable()
await expect
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
.toBe(`"${String.fromCodePoint(0x200b)}"`)
await input.click()
})
for (const lines of [6000, 25000]) {
test(`keeps a ${lines}-line crash report editable in a new session`, async ({ page }) => {
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(lines) + "End of report"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
const events = await input.evaluateHandle((element) => {
const events = { count: 0 }
element.addEventListener("input", () => events.count++)
return events
})
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
await expect(input).toBeFocused()
await page.keyboard.type("!")
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
})
}
for (const text of [
"single line <b> &amp;",
"first\nsecond",
"\n\n indented\ttext \n\nlast\n\n",
'literal <b>bold</b> &amp; & < > "quotes"\n<script>not code</script>\n<img src="example">',
"first\r\nsecond\rthird",
]) {
test(`preserves text and native undo: ${JSON.stringify(text)}`, async ({ page }) => {
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
await page.keyboard.press("ControlOrMeta+V")
const expected = text.replace(/\r\n?/g, "\n")
await expect.poll(() => input.innerText()).toBe(expected)
await expect(input.locator("b, script, img")).toHaveCount(0)
await page.keyboard.press("ControlOrMeta+Z")
await expect(input).toBeEmpty()
await page.keyboard.press("ControlOrMeta+Shift+Z")
await expect.poll(() => input.innerText()).toBe(expected)
})
}
test("replaces only the selected text and leaves the caret after the paste", async ({ page }) => {
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
await page.evaluate(() => navigator.clipboard.writeText("one\ntwo"))
await page.keyboard.type("before replace after")
await expect(input).toHaveText("before replace after")
await page.evaluate(() => document.fonts.ready)
const word = await input.evaluate((element) => {
const range = document.createRange()
range.setStart(element.firstChild!, 7)
range.setEnd(element.firstChild!, 14)
const rect = range.getBoundingClientRect()
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
})
await page.mouse.move(word.x, word.y)
await page.mouse.down()
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("replace")
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
await page.keyboard.press("ControlOrMeta+Z")
await expect(input).toHaveText("before replace after")
await page.keyboard.press("ControlOrMeta+Shift+Z")
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
await page.keyboard.type("!")
await expect.poll(() => input.innerText()).toBe("before one\ntwo! after")
})
@@ -1,178 +0,0 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:\\OpenCode\\main"
const workspace = "C:\\OpenCode\\worktree"
const projectID = "proj_mcp_workspace"
const sessionID = "ses_mcp_workspace"
const title = "Workspace MCP routing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
for (const shared of [true, false]) {
test(`toggles the workspace MCP when the default location ${shared ? "has" : "does not have"} the server`, async ({
page,
}, testInfo) => {
const connected = new Set<string>()
const requests: { path: string; directory: string }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "mcp-workspace",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [workspace],
},
provider: { all: [], connected: [], default: {} },
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/connect") {
connected.add(target)
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
connected.delete(target)
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: !shared && target !== workspace
? []
: [{ name: "figma-desktop", status: { status: connected.has(target) ? "connected" : "disabled" } }],
},
})
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
await dialog.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected).toEqual(new Set([workspace]))
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/connect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await testInfo.attach("workspace-connected", { body: await page.screenshot(), contentType: "image/png" })
requests.length = 0
await dialog.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected.size).toBe(0)
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/disconnect", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
})
}
for (const surface of ["popover", "dialog"] as const) {
test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }, testInfo) => {
const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found"
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
})
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "mcp-workspace",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [workspace],
},
provider: { all: [], connected: [], default: {} },
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "figma-desktop",
status: { status: target === workspace ? state.status : "connected", error },
},
],
},
})
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
await panel.locator('[data-slot="switch-control"]').click()
const toast = page
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
])
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await expect(toast).toHaveCSS("opacity", "1")
await testInfo.attach("mcp-connection-error", { body: await page.screenshot(), contentType: "image/png" })
if (surface === "popover") await page.keyboard.press("Escape")
if (surface === "dialog") await panel.getByRole("button", { name: "Close", exact: true }).click()
await expect(panel).toBeHidden()
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
await expect(toggle).toBeEnabled()
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await expect(toast).toBeHidden()
})
}
@@ -1,308 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/WorkspacePending"
const workspace = "C:/OpenCode/pending-workspace"
const projectID = "proj_workspace_pending"
const draftID = "draft_workspace_pending"
const otherID = "ses_workspace_pending_other"
const text = "Create the workspace, then explain the pending session."
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionPath = `/server/${base64Encode(server)}/session/`
const draftPath = `/new-session?draftId=${draftID}`
const headers = { "access-control-allow-origin": "*" }
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 },
]) {
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await testInfo.attach("creating-worktree", {
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
contentType: "image/png",
})
if (viewport.name === "mobile") {
await page.locator("html").evaluate((element) => {
element.dir = "rtl"
})
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
expect(
await page
.locator('[data-component="session-preparing"]')
.evaluate((element) => element.scrollWidth <= element.clientWidth),
).toBe(true)
}
if (viewport.name === "desktop") {
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(pending.shimmer).toBeHidden()
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
await expect(page).toHaveURL(pending.url)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
expect(mock.calls).toEqual(["worktree"])
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await page.locator('[data-component="composer-editor"]').fill("Keep focus in this other session")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
}
expect(mock.calls).toEqual(["worktree"])
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect
.poll(() => mock.prompts)
.toEqual([{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) }])
expect(mock.creates).toEqual([
expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } }),
])
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
if (viewport.name === "desktop") {
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toContainText(
"Created workspace session",
)
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep focus in this other session")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
}
await expect(page).toHaveURL(pending.url)
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(1)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
})
}
test("restores the original draft when worktree creation fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed in the fixture" } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0)
expect(mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
})
test("retains the draft and reuses the created workspace after session creation fails", async ({ page }) => {
const mock = await openDraft(page, { failSessionCreate: true })
const pending = await submitPending(page, mock)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
expect(mock.calls).toEqual(["worktree", "session"])
expect(mock.prompts).toEqual([])
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.creates).toHaveLength(2)
expect(mock.creates[1]).toMatchObject({ location: { directory: workspace } })
expect(mock.prompts[0]).toMatchObject({ sessionID: mock.creates[1].id, body: { text } })
expect(mock.calls).toEqual(["worktree", "session", "session", "prompt"])
await expect(page).toHaveURL(`${sessionPath}${mock.creates[1].id}`)
await expect(page.locator('[data-component="user-message"] [data-slot="user-message-text"]')).toHaveText(text)
})
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(tab).toHaveCount(0)
await expect(pending.shimmer).toHaveCount(0)
await page.goBack()
await expect(page).toHaveURL(pending.url)
await expect(tab).toHaveCount(1)
await expect(tab).toBeVisible()
await expect(pending.message).toHaveCount(1)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toBeVisible()
await expect(pending.shimmer).toContainText("Creating worktree")
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
expect(mock.calls).toEqual(["worktree"])
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
await expect(tab).toHaveCount(0)
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
expect(mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
})
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
id: projectID,
worktree: directory,
vcs: "git",
name: "workspace-pending",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [] as string[],
}
const sessions = [currentSession({ id: otherID, projectID, title: "Other session" }, directory)]
await mockOpenCodeServer(page, {
directory,
project,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "pending-model": { id: "pending-model", name: "Pending Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "pending-model" },
},
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
})
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) {
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
await page.route(`**/api/worktree/${projectID}`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
})
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: Record<string, unknown> = route.request().postDataJSON()
creates.push(body)
if (options?.failSessionCreate && creates.length === 1) {
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
}
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
sessions.push(session)
return route.fulfill({ json: { data: session }, headers })
})
await page.route("**/api/location?**", (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: projectID, directory, canonical: directory },
},
headers,
})
})
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory },
{ type: "session", sessionId: otherID, server },
]),
)
},
{ directory, draftID, otherID, server },
)
await page.goto(draftPath)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
await page.locator('[data-component="composer-editor"]').fill(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
const url = page.url()
const sessionID = new URL(url).pathname.slice(sessionPath.length)
const preparing = page.locator('[data-component="session-preparing"]')
const message = page.locator('[data-component="user-message"]')
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
await expect(preparing).toBeVisible()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
await expect(shimmer).toBeVisible()
await expect(shimmer).toContainText("Creating worktree")
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect.poll(() => mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
return { url, sessionID, messageID, message, shimmer }
}
@@ -1,89 +0,0 @@
import { expect, test } from "@playwright/test"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
for (const profile of [
{ locale: "en", direction: "ltr" },
{ locale: "en", direction: "rtl" },
{ locale: "ar", direction: "rtl" },
]) {
test(`keeps notices on one line: ${profile.locale} ${profile.direction} ${width}`, async ({ page }, info) => {
const command =
"bun run inspect --target src/renderer/session-timeline.ts --output artifacts/inspection-report.json ".repeat(5)
const descriptions = [
`${command}--finished`,
`Instructions changed\n${command}--updated`,
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
{
id: "msg_notice_user",
type: "user",
text: "Inspect the project and report completion.",
time: { created: 1 },
},
{
id: "msg_notice_shell",
type: "synthetic",
text: "Complete",
description: descriptions[0],
metadata: { source: "shell", state: "completed" },
time: { created: 2 },
},
{ id: "msg_notice_system", type: "system", text: descriptions[1], time: { created: 3 } },
{
id: "msg_notice_agent",
type: "synthetic",
text: "Complete",
description: descriptions[2],
metadata: { source: "subagent", state: "completed", agent: "general" },
time: { created: 4 },
},
],
})
await page
.locator("html")
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(3)
await expect(notices).toContainText(descriptions)
await page.locator("[data-timeline-virtual-content]").screenshot({ path: info.outputPath("notices.png") })
await expect
.poll(() =>
notices.evaluateAll((nodes) =>
nodes.map((node) => {
const style = getComputedStyle(node)
const element = node as HTMLElement
return {
direction: style.direction,
whiteSpace: style.whiteSpace,
textOverflow: style.textOverflow,
overflow: style.overflowX,
singleLine:
Math.abs(
element.clientHeight -
parseFloat(style.paddingTop) -
parseFloat(style.paddingBottom) -
parseFloat(style.lineHeight),
) <= 1,
clipped: element.scrollWidth > element.clientWidth,
}
}),
),
)
.toEqual(
Array.from({ length: 3 }, () => ({
direction: profile.direction,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
singleLine: true,
clipped: true,
})),
)
})
}
}
@@ -261,7 +261,7 @@ for (const delivery of ["steer", "queue"] as const) {
const transcript = page.locator("[data-timeline-virtual-content]")
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible()
await expect(thinking).toHaveCount(0)
await expect(thinking).toBeVisible()
await expect(view.input).toBeEditable()
await view.input.fill(followUp)
await view.input.press("Enter")
@@ -274,14 +274,12 @@ for (const delivery of ["steer", "queue"] as const) {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
await expect(pending).toHaveCount(0)
await expect(thinking).toHaveCount(0)
await queued.hover()
await queued.getByRole("button", { name: "Steer", exact: true }).click()
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
}
await expect(view.rows).toHaveCount(0)
await expect(pending).toContainText(followUp)
await expect(thinking).toHaveCount(0)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
@@ -308,9 +306,9 @@ for (const delivery of ["steer", "queue"] as const) {
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
await expect(thinking).toHaveCount(0)
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(thinking).toBeVisible()
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
{ id: inboxID, delivery: "steer" },
@@ -318,21 +316,27 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
await expect
.soft(tools.or(thinking).or(pending))
.toHaveText([/Used\s*Read, Grep/, /Thinking/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
await expect
.configure({ soft: true })
.poll(async () => {
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
const boxes = await Promise.all([tools.boundingBox(), thinking.boundingBox(), pending.boundingBox()])
return (
boxes.every((box) => box !== null) &&
boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y &&
boxes[1]!.y + boxes[1]!.height <= boxes[2]!.y
)
})
.toBe(true)
mock.rows.splice(0, 1)
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveCount(0)
await expect(thinking).toHaveAttribute("data-message-id", inboxID)
await expect(pending).toHaveCount(1)
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
@@ -348,11 +352,11 @@ for (const delivery of ["steer", "queue"] as const) {
.locator('[data-timeline-row="AssistantPart"]')
.filter({ hasText: "A3: Now checking the retry path for U2." })
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/^Used\s*2 Read, Grep$/,
await expect(tools.or(pending).or(response).or(thinking)).toHaveText([
/Used\s*Read, Grep/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
/Thinking/,
])
})
}
@@ -1,191 +0,0 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, ShellInfo } from "@opencode-ai/client/promise"
import { directory, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const shell = {
id: "sh_background",
status: "running",
command: "bun run check",
cwd: directory,
shell: "bash",
file: "/tmp/check.out",
metadata: { sessionID },
time: { started: 2 },
} satisfies ShellInfo
for (const grouped of [false, true]) {
for (const status of ["exited", "killed", "timeout"] as const) {
test(`stops ${grouped ? "grouped" : "standalone"} background shell shimmer when ${status}`, async ({
page,
}, info) => {
const message: SessionMessageAssistant = {
id: "msg_background",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [shell.id, "sh_other"].map((id) => ({
type: "tool",
id: `call_${id}`,
name: "shell",
state: {
status: "completed",
input: { command: shell.command },
content: [{ type: "text", text: "Command moved to the background." }],
metadata: { shellID: id, status: "running" },
},
time: { created: 2, completed: 3 },
})),
time: { created: 2, completed: 3 },
}
if (grouped)
message.content.unshift({
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "package.json" },
content: [{ type: "text", text: "{}" }],
metadata: {},
},
time: { created: 1, completed: 2 },
})
const timeline = await setupTimeline(page, {
viewport: { width: grouped ? 390 : 1400, height: 900 },
settings: { shellToolPartsExpanded: !grouped },
sessionStatus: { [sessionID]: { type: "busy" } },
sessionMessages: [
{ id: "msg_user", type: "user", text: "Run two independent checks.", time: { created: 1 } },
message,
],
})
const state = { finished: false, requests: 0 }
await page.route("**/api/shell?*", (route) =>
route.fulfill({
json: { location: { directory }, data: [...(state.finished ? [] : [shell]), { ...shell, id: "sh_other" }] },
}),
)
await page.route("**/api/shell/*/output?*", (route) => {
const url = new URL(route.request().url())
const target = url.pathname.includes(`/${shell.id}/`)
if (target) state.requests++
const output = target && state.finished ? "Checking project\nCheck finished\n" : "Checking project\n"
const cursor = Number(url.searchParams.get("cursor") ?? 0)
const end = Math.min(output.length, cursor + 17)
return route.fulfill({
json: {
location: { directory },
data: {
output: output.slice(cursor, end),
cursor: end,
size: output.length,
truncated: false,
},
},
})
})
await page.clock.install()
await page.reload()
await timeline.transport.waitForConnection()
const group = page.locator('[data-component="collapsed-tool-group"]')
const groupTrigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
if (grouped) {
await expect(group).toHaveAttribute("data-timeline-part-ids", "call_read,call_sh_background,call_sh_other")
await expect(groupTrigger).toHaveAttribute("aria-expanded", "false")
await groupTrigger.click()
}
const card = page.locator(`[data-timeline-part-id="call_${shell.id}"]`)
const shimmer = card.locator('[data-component="text-shimmer"]')
const other = page.locator('[data-timeline-part-id="call_sh_other"] [data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(other).toHaveAttribute("data-active", "true")
if (grouped) await card.locator('[data-slot="collapsible-trigger"]').click()
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
state.finished = true
await timeline.transport.send({
id: "evt_shell_exited",
created: 4,
type: "shell.exited",
location: { directory },
data: { id: shell.id, status, exit: status === "exited" ? 0 : 1 },
})
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(other).toHaveAttribute("data-active", "true")
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project\nCheck finished")
await expect(card.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await page.locator("[data-timeline-virtual-content]").screenshot({ path: info.outputPath("shell-finished.png") })
const requests = state.requests
await page.clock.fastForward(5_000)
expect(state.requests).toBe(requests)
await page.reload()
if (grouped) await groupTrigger.click()
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(other).toHaveAttribute("data-active", "true")
})
}
}
test("shows the authoritative foreground result after streaming shell output", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
sessionMessages: [
{ id: "msg_user", type: "user", text: "Run the check.", time: { created: 1 } },
{
id: "msg_foreground",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_foreground",
name: "shell",
state: { status: "running", input: { command: shell.command }, metadata: { shellID: shell.id } },
time: { created: 2 },
},
],
time: { created: 2 },
},
],
})
await page.route("**/api/shell/*/output?*", (route) =>
route.fulfill({
json: {
location: { directory },
data: {
output: Number(new URL(route.request().url()).searchParams.get("cursor")) === 0 ? "Checking project\n" : "",
cursor: 17,
size: 17,
truncated: false,
},
},
}),
)
await page.reload()
await timeline.transport.waitForConnection()
const card = page.locator('[data-timeline-part-id="call_foreground"]')
const shimmer = card.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
await timeline.transport.send({
id: "evt_foreground_complete",
created: 3,
type: "session.tool.success",
durable: { aggregateID: sessionID, seq: 0, version: 2 },
data: {
sessionID,
assistantMessageID: "msg_foreground",
id: "call_foreground",
executed: true,
content: [{ type: "text", text: "Checking project\nCheck finished\nCommand exited with code 0." }],
metadata: { status: "completed", exit: 0 },
},
})
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(card.locator('[data-slot="bash-result"]')).toHaveText(
"Checking project\nCheck finished\nCommand exited with code 0.",
)
})
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
const trigger = page.getByRole("button", { name: "Used Shell" })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
@@ -84,41 +84,6 @@ const assistantMessage = {
} satisfies SessionMessageInfo
test.describe("regression: session timeline local row state", () => {
test("preserves a patch file choice as new calls join its Used group", async ({ page }) => {
const events: EventPayload[] = []
const part = { ...editPart, tool: "patch" }
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
await configurePage(page, false)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
await expect(summary).toHaveAccessibleName("Used 1 Patch")
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
})
const wrapper = group.locator('[data-disclosure-probe="existing"]')
const trigger = wrapper.locator('[data-scope="apply-patch"] button')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const original = await wrapper.elementHandle()
for (const count of [2, 3]) {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
}
})
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
@@ -243,19 +208,19 @@ test.describe("regression: session timeline local row state", () => {
})
})
async function configurePage(page: Page, expanded = true) {
await page.addInitScript((expanded) => {
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: expanded,
shellToolPartsExpanded: expanded,
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
},
}),
)
}, expanded)
})
}
async function expectExpanded(locator: Locator, expected: boolean) {
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -28,7 +28,7 @@ for (const expanded of [false, true]) {
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
: page.getByRole("button", { name: "Used Shell" })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -109,85 +109,31 @@ test("shimmers and expands a running shell command", async ({ page }) => {
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
const shellID = `prt_reasoning_shell_${open}`
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
await expect(thought).toHaveAttribute("aria-expanded", "false")
await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.send(partUpdated(shell(shellID, "running")))
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(thought).toContainText("Thought")
await expect(thought).not.toContainText("Inspecting stability")
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
}
await timeline.send(status("busy"), 150)
for (const transition of ["reasoning-end", "idle", "retry"] as const) {
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
const id = `prt_reasoning_stop_${transition}`
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(transition === "reasoning-end" ? partUpdated(reasoningPart(id, text)) : status(transition))
await expect(trigger).toContainText("Thought")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(transition === "retry" ? 1 : 0)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(part.getByText("The timeline is ready for the next step.", { exact: true })).toBeVisible()
})
}
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
})
test("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ page }) => {
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
const assistant = assistantMessage([], { completed: false })
const timeline = await setupTimeline(page, {
messages: [
@@ -207,17 +153,18 @@ test("does not infer Thinking from busy, retry, or recovery without reasoning",
assistant,
],
})
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(status("busy"), 140)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"))
await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(stepStarted(assistant))
await timeline.send(stepStarted(assistant), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
"Recovered response",
@@ -16,9 +16,8 @@ for (const locale of ["de", "ar"] as const) {
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -5,7 +5,6 @@ import {
compactionEnded,
compactionFailed,
compactionStarted,
directory,
event,
session,
sessionID,
@@ -209,56 +208,6 @@ test("navigates from a running subagent card and hides background controls in th
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
sessionMessages: [
user,
{
...message,
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name,
state: {
status: "running",
input:
name === "shell" ? { command: "echo checking" } : { agent: "general", description: "Inspect code" },
metadata: {},
},
time: { created: 3 },
},
],
},
],
})
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
}
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
@@ -349,24 +298,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
await timeline.transport.send({
id: "evt_background_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_backgrounded",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/background.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
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()
@@ -4,144 +4,89 @@ import {
assistantMessage,
reasoningPart,
setupTimeline,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "## Inspecting stability",
other: false,
thinking: true,
body: false,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "## Inspecting stability",
other: true,
thinking: true,
body: false,
},
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: " ",
other: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "## Inspecting stability",
other: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "",
other: true,
thinking: false,
body: false,
},
] as const
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
for (const profile of profiles) {
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
const parts = [
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
...(profile.other
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
: []),
]
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts, { completed: false })],
settings: { showReasoningSummaries: profile.summaries },
})
}
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
],
settings: { showReasoningSummaries: true },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
completedAssistantInfo,
@@ -45,11 +44,10 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
@@ -75,69 +73,11 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
test("combines follow-up patches into one three-file stack inside Used", async ({ page }) => {
const file = (path: string, before: number, after: number) => ({
file: path,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(
path,
path,
`export const value = ${before}\n`,
`export const value = ${after}\n`,
"",
"",
{ context: Infinity },
),
})
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
shell("patch_shell", "completed"),
toolPart(
"patch_first",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
},
),
]),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
toolPart(
"patch_next",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
},
),
),
)
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
@@ -162,8 +102,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "Used Explore" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import pkg from "../../package.json" with { type: "json" }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
@@ -176,9 +175,6 @@ test("appearance experimental setting switches tab orientation", async ({ page }
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
await expect(version).toBeInViewport()
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
@@ -198,40 +194,6 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(version).toBeInViewport()
await page.setViewportSize({ width: 390, height: 720 })
await expect(version).toBeInViewport()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(version).toBeInViewport()
await expect(version).toHaveCSS("direction", "ltr")
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await expect(
page
.locator('[data-slot="titlebar-tabs"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(layout).toContainText("Vertical")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
@@ -1,194 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionReady } from "../utils/waits"
const root = "C:/OpenCode/WorkspaceAccent"
const workspace = `${root}/.worktrees/feature`
const projectID = "proj_workspace_accent"
const sessionID = "ses_workspace_accent"
const title = "Workspace accent regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const inventory: WorktreeDirectory[] = [
{ directory: root },
{ directory: workspace, strategy: "git" },
{ directory: "C:/OpenCode/LinkedWorkspace", strategy: "git" },
{ directory: "C:/OpenCode/WorkspaceCopy", strategy: "copy" },
{ directory: "C:/OpenCode/RegisteredDirectory" },
]
test.use({ serviceWorkers: "block" })
for (const scenario of [
{ name: "managed Git worktree", directory: workspace, accent: true },
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
{
name: "linked Git worktree on a narrow screen",
directory: "C:/OpenCode/LinkedWorkspace",
accent: true,
viewport: { width: 390, height: 844 },
},
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
{
name: "workspace with Windows case and separators",
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
accent: true,
},
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
]) {
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
const view = await openSession(page, scenario.directory)
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
if (scenario.name === "managed Git worktree") {
// Capture before the color assertion so both red and green runs have evidence.
const path = testInfo.outputPath("workspace-accent.png")
await view.composer.screenshot({ path })
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
}
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
})
}
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
page,
}) => {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
const url = page.url()
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
id: "evt_workspace_accent_inventory",
created: 1700000001000,
type: "worktree.updated",
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "accent")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
view.events.push({
id: "evt_workspace_accent_running",
created: 1700000002000,
type: "session.execution.started",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "accent")
await expect(page).toHaveURL(url)
})
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
canonical: root,
worktree: root,
vcs: "git",
name: "workspace-accent",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "accent-model": { id: "accent-model", name: "Accent Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "accent-model" },
},
sessions: [
{
id: sessionID,
projectID,
directory,
title,
model: { id: "accent-model", providerID: "opencode" },
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({
items: [
{
id: "msg_workspace_accent",
type: "user",
text: "Check this fixture workspace.",
time: { created: 1700000000000 },
},
],
}),
events: () => events.splice(0),
})
// Keep authoritative inventory independent of the raw project's empty sandboxes.
await page.route(`**/api/worktree/${projectID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "light")
})
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
expect((await loaded).ok()).toBe(true)
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
await expect(input).toBeEditable()
await expect(composer.locator('[data-action="composer-model"]')).toHaveText("Accent Model")
return { composer, input, send: composer.getByRole("button", { name: "Send", exact: true }), events, worktrees }
}
async function expectBackground(element: Locator, token: string, property = "background-image") {
const color = await element.evaluate((element, token) => {
// Resolve semantic colors through the browser, without reproducing the button's gradient.
const probe = document.createElement("span")
probe.hidden = true
probe.style.backgroundColor = `var(--v2-background-bg-${token})`
element.append(probe)
const color = getComputedStyle(probe).backgroundColor
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
+99 -357
View File
@@ -1,124 +1,60 @@
import { expect, test, type Page } from "@playwright/test"
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { createServer, type ServerResponse } from "node:http"
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { once } from "node:events"
import { createHash } from "node:crypto"
import { join, extname, relative, sep } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { build } from "vite"
import { serviceWorker } from "../../vite.pwa"
type Site = {
url: string
deploy: (fault?: "failed" | "html" | "corrupt" | "mixed-html" | "blocked") => void
legacy: () => void
requests: string[]
release: () => void
}
const legacy = `
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache =>
cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"])
)
))
self.addEventListener("fetch", event => {
if (event.request.mode === "navigate") {
event.respondWith(caches.match("/index.html"))
return
}
event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)))
})
`
const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<string, Buffer>> }>({
builds: [
async ({}, use) => {
const directory = await mkdtemp(join(tmpdir(), "opencode-precache-"))
const builds: Record<string, Record<string, Buffer>> = {}
try {
for (const version of ["old", "new"]) {
const root = join(directory, version)
const outDir = join(root, "dist")
await mkdir(join(root, "public", "nested"), { recursive: true })
await Promise.all(
Object.entries({
"index.html": `<html><head></head><body><h1>Loading</h1><label>Draft<textarea></textarea></label><button>Load lazy</button><output></output><script type="module" src="/main.js"></script></body></html>`,
"main.js": `document.querySelector("h1").textContent = "${version}";
document.querySelector("button").onclick = async () => {
document.querySelector("output").textContent = await (await import("./lazy.js")).load()
};`,
"lazy.js": `export async function load() { return (await import("./nested.js")).value }`,
"nested.js": `export const value = "${version} nested lazy loaded"`,
"public/nested/data.json": JSON.stringify({ version }),
"public/nested/font.woff2": `font-${version}`,
"public/nested/module.wasm": Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]),
"public/large.bin": Buffer.alloc(2 * 1024 * 1024 + 1, version === "old" ? 1 : 2),
"public/_headers": "/*\n Cache-Control: no-cache",
"public/_redirects": "/* /index.html 200",
}).map(([path, contents]) => writeFile(join(root, path), contents)),
)
await build({
configFile: false,
root,
logLevel: "silent",
build: { outDir, assetsDir: "_assets", sourcemap: true },
plugins: serviceWorker(outDir),
})
builds[version] = Object.fromEntries(
await Promise.all(
(await readdir(outDir, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map(async (entry) => {
const path = join(entry.parentPath, entry.name)
return ["/" + relative(outDir, path).split(sep).join("/"), await readFile(path)]
}),
),
)
}
await use(builds)
} finally {
await rm(directory, { recursive: true, force: true })
}
},
{ scope: "worker" },
],
site: async ({ builds }, use) => {
const state = { version: "old", fault: "", legacy: false }
const requests: string[] = []
const blocked: ServerResponse[] = []
const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"]))
const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({
site: async ({}, use) => {
const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8")
const state = { version: "old", repaired: false }
const server = createServer((request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
requests.push(path)
const pathname = new URL(request.url ?? "/", "http://localhost").pathname
const prefix = state.version === "old" ? "/assets" : "/_assets"
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/health")
return void response.writeHead(200, { "content-type": "application/json" }).end('{"healthy":true}')
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
(path) => path === "/index.html" || (path.startsWith("/_assets/") && path.endsWith(".js")),
)
if (pathname === "/sw.js") {
response.setHeader("content-type", "text/javascript")
return void response.end(`
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache => cache.addAll(${JSON.stringify(urls)}))
));
self.addEventListener("fetch", event => event.respondWith(
caches.match(event.request.mode === "navigate" ? "/index.html" : event.request)
.then(response => response || fetch(event.request))
));
response.end(state.version === "old" ? legacy : worker)
return
}
if (pathname === `${prefix}/app-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`import "${prefix}/startup-${state.version}.js"`)
return
}
if (pathname === `${prefix}/startup-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`
document.getElementById("root").innerHTML = '<h1>${state.version}</h1><label>Draft<input></label><button>Load older chunk</button><output></output>'
document.querySelector("button").onclick = () => import("/assets/lazy-old.js")
`)
return
}
if (path === "/index.html" && state.fault === "mixed-html")
return void response.writeHead(200, { "content-type": "text/html" }).end(builds.old["/index.html"])
if (path === "/large.bin" && state.fault && state.fault !== "mixed-html") {
if (state.fault === "blocked") return void blocked.push(response)
if (state.fault === "failed") return void response.writeHead(503).end("Unavailable")
if (state.fault === "html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<html>Wrong fallback</html>")
return void response.end("Incorrect bytes with a successful status")
if (
(pathname === "/assets/lazy-old.js" && state.version === "old") ||
(pathname === "/_assets/retry.js" && state.repaired)
) {
response.setHeader("content-type", "text/javascript")
response.end('document.querySelector("output").textContent = "Older chunk loaded"')
return
}
const file = builds[state.version][path]
const types: Record<string, string> = {
".js": "text/javascript",
".html": "text/html",
".json": "application/json",
".wasm": "application/wasm",
}
response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream")
if (file) return void response.end(file)
if (extname(path)) return void response.writeHead(404).end("Not found")
// Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself.
response.setHeader("content-type", "text/html")
response.end(builds[state.version]["/index.html"])
response.end(`<div id="root"></div><script type="module" src="${prefix}/app-${state.version}.js"></script>`)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
@@ -127,269 +63,75 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
try {
await use({
url: `http://127.0.0.1:${address.port}`,
deploy: (fault = undefined) => {
state.version = "new"
state.fault = fault ?? ""
},
legacy: () => {
state.legacy = true
},
requests,
release,
upgrade: () => (state.version = "new"),
repair: () => (state.repaired = true),
})
} finally {
release()
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
},
})
async function install(page: Page, url: string) {
await page.goto(url)
fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => {
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.reload()
await expect.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.state)).toBe("activated")
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
}
await page.getByLabel("Draft").fill("Keep this unsent prompt")
async function update(page: Page) {
return page.evaluateHandle(async () => {
site.upgrade()
await page.evaluate(async () => {
const cache = await caches.open("opencode-assets")
await cache.put(
"/_assets/startup-new.js",
new Response("<html>stale fallback</html>", {
headers: { "content-type": "text/html" },
}),
)
const changed = new Promise<void>((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }),
)
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) throw new Error("Missing installed worker")
const found = new Promise<ServiceWorker>((resolve) =>
registration.addEventListener(
"updatefound",
() => {
if (!registration.installing) throw new Error("Missing installing worker")
resolve(registration.installing)
},
{ once: true },
if (!registration) throw new Error("Missing legacy worker")
await registration.update()
await changed
})
await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.getByRole("button", { name: "Load older chunk" }).click()
await expect(page.getByRole("status")).toHaveText("Older chunk loaded")
await page.goto(`${site.url}/workspace/example`)
await expect(page.getByRole("heading")).toHaveText("new")
await expect
.poll(() =>
page.evaluate(async () =>
(await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"),
),
)
await registration.update()
return found
.toBe("text/javascript")
})
fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => {
site.upgrade()
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
}
async function waiting(page: Page) {
await expect
.poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting?.state))
.toBe("installed")
}
fixture(
"opens an uncached route offline and executes never-used nested lazy chunks",
async ({ page, context, site }) => {
await install(page, site.url)
await expect(page.getByRole("status")).toBeEmpty()
await context.setOffline(true)
await page.goto(`${site.url}/workspace/never-visited`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
},
)
fixture(
"precaches public files of every type and size, excluding deployment metadata and source maps",
async ({ page, site, builds, context }) => {
for (const output of Object.values(builds)) {
expect(output["/sw.js.map"]).toBeUndefined()
expect(Object.keys(output).some((path) => path.startsWith("/_assets/") && path.endsWith(".map"))).toBe(true)
}
await install(page, site.url)
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
await context.setOffline(true)
for (const path of files) {
const digest = await page.evaluate(
async (path) =>
Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await (await fetch(path)).arrayBuffer()))),
path,
)
expect(Buffer.from(digest)).toEqual(createHash("sha256").update(builds.old[path]).digest())
}
expect(site.requests).not.toContain("/_headers")
expect(site.requests).not.toContain("/_redirects")
expect(site.requests.filter((path) => path.endsWith(".map"))).toEqual([])
},
)
fixture(
"keeps drafts and removed old lazy chunks until every controlled tab closes",
async ({ page, context, site, builds }) => {
await install(page, site.url)
const second = await context.newPage()
await second.goto(site.url)
await expect(second.getByRole("heading")).toHaveText("old")
await second.getByLabel("Draft").fill("Keep this unsent prompt")
site.deploy()
const created = context.waitForEvent("serviceworker")
const worker = await update(page)
const replacement = await created
await waiting(page)
expect(await worker.evaluate((worker) => worker.state)).toBe("installed")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.close()
await waiting(second)
await expect(second.getByRole("heading")).toHaveText("old")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
const removed = Object.keys(builds.old).find((path) => path.includes("/nested-") && path.endsWith(".js"))
expect(removed).toBeDefined()
expect((await second.request.get(`${site.url}${removed}`)).status()).toBe(404)
await second.getByRole("button", { name: "Load lazy" }).click()
await expect(second.getByRole("status")).toHaveText("old nested lazy loaded")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await second.close()
await expect
.poll(() =>
replacement.evaluate(() => {
const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration
return { waiting: !!registration.waiting, active: registration.active?.state }
}),
)
.toEqual({ waiting: false, active: "activated" })
await context.setOffline(true)
const observer = await context.newPage()
await observer.goto(`${site.url}/workspace/reopened`)
await expect(observer.getByRole("heading")).toHaveText("new")
await observer.getByRole("button", { name: "Load lazy" }).click()
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
},
)
for (const fault of ["failed", "html", "corrupt", "mixed-html"] as const) {
fixture(`retains the old complete build when a precache download is ${fault}`, async ({ page, context, site }) => {
await install(page, site.url)
await page.getByLabel("Draft").fill("Still editing")
site.deploy(fault)
const worker = await update(page)
await expect.poll(() => worker.evaluate((worker) => worker.state)).toBe("redundant")
expect(await page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting)).toBeNull()
await expect(page.getByLabel("Draft")).toHaveValue("Still editing")
await context.setOffline(true)
await page.goto(`${site.url}/workspace/after-failure`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
})
}
fixture("does not expose new HTML while a precache download is blocked", async ({ page, context, site }) => {
await install(page, site.url)
site.requests.length = 0
site.deploy("blocked")
const worker = await update(page)
await expect.poll(() => site.requests.includes("/large.bin")).toBe(true)
expect(await worker.evaluate((worker) => worker.state)).toBe("installing")
const second = await context.newPage()
await second.goto(`${site.url}/workspace/during-install`)
await expect(second.getByRole("heading")).toHaveText("old")
site.release()
await waiting(page)
await second.reload()
await expect(second.getByRole("heading")).toHaveText("old")
})
fixture("upgrades the legacy shared precache only after old tabs close", async ({ page, context, site, builds }) => {
site.legacy()
const observer = await context.newPage()
await observer.goto(`${site.url}/observer.html`)
await install(page, site.url)
await page.getByLabel("Draft").fill("Legacy unsent prompt")
// A stale runtime-cache HTML response must not contaminate the new generated precache.
const entry = Object.keys(builds.new).find((path) => path.includes("/index-") && path.endsWith(".js"))
expect(entry).toBeDefined()
await page.evaluate(async (entry) => {
await (
await caches.open("opencode-assets")
).put(entry!, new Response("<html>stale fallback</html>", { headers: { "content-type": "text/html" } }))
}, entry)
site.deploy()
await update(page)
await waiting(page)
await expect(page.getByLabel("Draft")).toHaveValue("Legacy unsent prompt")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
await page.close()
await expect
.poll(() => observer.evaluate(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting))
.toBe(false)
await context.setOffline(true)
await observer.goto(`${site.url}/workspace/legacy-upgraded`)
await expect(observer.getByRole("heading")).toHaveText("new")
await observer.getByRole("button", { name: "Load lazy" }).click()
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
})
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/health`)
expect(await api?.json()).toEqual({ healthy: true })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
expect(await asset?.text()).toBe("Not found")
})
test("the production build precaches every deployable file", async ({ page, context }) => {
const directory = new URL("../../dist/", import.meta.url)
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
expect(files.length).toBeGreaterThan(1)
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
response.setHeader("cache-control", "no-store")
if (path === "/probe.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Precache probe</title>")
const bytes = await readFile(new URL(`.${path}`, directory)).catch(() => undefined)
if (!bytes) return void response.writeHead(404).end("Not found")
if (path.endsWith(".js")) response.setHeader("content-type", "text/javascript")
if (path.endsWith(".html")) {
response.setHeader("content-type", "text/html")
// Inspect the real cached HTML without executing the app or contacting a backend.
response.setHeader("content-security-policy", "default-src 'none'")
}
response.end(bytes)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
const url = `http://127.0.0.1:${address.port}`
try {
await page.goto(`${url}/probe.html`)
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
const cached = await page.evaluate(async () =>
(
await Promise.all(
(await caches.keys()).map(async (name) =>
(await (await caches.open(name)).keys()).map((request) => new URL(request.url).pathname),
),
)
)
.flat()
.sort(),
)
expect(cached).toEqual(files.sort())
await context.setOffline(true)
const response = await page.goto(`${url}/workspace/offline-probe`)
expect(response?.fromServiceWorker()).toBe(true)
expect(await response?.text()).toBe(await readFile(new URL("index.html", directory), "utf8"))
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/html",
)
site.repair()
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/javascript",
)
})
@@ -1,14 +1,9 @@
import { defineConfig } from "@playwright/test"
// Tiny fixture builds do not need a Rolldown thread for every host CPU.
process.env.RAYON_NUM_THREADS ??= "2"
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/service-worker",
timeout: 60_000,
workers: 1,
expect: { timeout: 15_000 },
timeout: 30_000,
use: { browserName: "chromium" },
})
+4 -2
View File
@@ -7,8 +7,10 @@
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<title>OpenCode</title>
<link rel="icon" type="image/x-icon" href="%OPENCODE_FAVICON%" />
<link rel="apple-touch-icon" sizes="180x180" href="%OPENCODE_APPLE_TOUCH_ICON%" />
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#fafafa" />
<meta name="mobile-web-app-capable" content="yes" />
-24
View File
@@ -1,24 +0,0 @@
{
"name": "OpenCode",
"short_name": "OpenCode",
"id": "/",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#080808",
"background_color": "#080808",
"display": "standalone"
}
+13 -5
View File
@@ -42,18 +42,21 @@
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"vite": "8.2.2",
"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": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -68,23 +71,28 @@
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "catalog:",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "catalog:",
"@solid-primitives/resize-observer": "catalog:",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
}

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