mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
152
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbbb83ec1c | ||
|
|
cf2c3a536d | ||
|
|
7852cecd72 | ||
|
|
6cfffeb031 | ||
|
|
6e954f75ee | ||
|
|
0116a98371 | ||
|
|
a38cbd42aa | ||
|
|
4ab31867c4 | ||
|
|
51a082cea3 | ||
|
|
e3bda5e2d0 | ||
|
|
ad9117b107 | ||
|
|
6b7a1d419c | ||
|
|
c0d0f5f4bc | ||
|
|
095ed63ea0 | ||
|
|
fa5ccac707 | ||
|
|
6809be2d0a | ||
|
|
a5f8869b35 | ||
|
|
1c9c5305a6 | ||
|
|
cf014bf2c1 | ||
|
|
8e7190f795 | ||
|
|
82b6e0e316 | ||
|
|
5b39f5184f | ||
|
|
1da25727b2 | ||
|
|
51d53f45c1 | ||
|
|
f779b2748a | ||
|
|
f2fb191f53 | ||
|
|
0b32bdf1e5 | ||
|
|
2751813454 | ||
|
|
f61858e683 | ||
|
|
87525e00b9 | ||
|
|
803b7718b8 | ||
|
|
8a24a01bff | ||
|
|
9e39a4fbdf | ||
|
|
d0baff184b | ||
|
|
d82a0b28a9 | ||
|
|
bd379e13cb | ||
|
|
6e1f783aec | ||
|
|
edef6a4b15 | ||
|
|
5990679ebd | ||
|
|
b0c8a8c827 | ||
|
|
d837ffe70f | ||
|
|
b1d7dd82fc | ||
|
|
e409567428 | ||
|
|
9538c2171f | ||
|
|
0a718be0d9 | ||
|
|
67845091ba | ||
|
|
fe788b7842 | ||
|
|
ee42eb3ca3 | ||
|
|
80323a4deb | ||
|
|
a35f96f427 | ||
|
|
d354c3d640 | ||
|
|
ce005ce002 | ||
|
|
964245bc2a | ||
|
|
aea3e7c1d2 | ||
|
|
3625942952 | ||
|
|
0593a6b8eb | ||
|
|
426e5c6389 | ||
|
|
ebdfcf4866 | ||
|
|
000d0882c3 | ||
|
|
6062e30cb9 | ||
|
|
3badee1a3c | ||
|
|
4a0256d374 | ||
|
|
52ec62bef0 | ||
|
|
31af9858fd | ||
|
|
3151660fbb | ||
|
|
0362ef48ff | ||
|
|
facd7ff452 | ||
|
|
134cdda333 | ||
|
|
5634ef1bb6 | ||
|
|
2379ab3d51 | ||
|
|
5c908ebba5 | ||
|
|
ba0755d933 | ||
|
|
f7d6b00c1e | ||
|
|
0bb6cf37be | ||
|
|
da57b27277 | ||
|
|
42a3fec594 | ||
|
|
d15034264b | ||
|
|
1ce3c7e580 | ||
|
|
07f27c4eca | ||
|
|
196893cfeb | ||
|
|
f6992059be | ||
|
|
732f949a65 | ||
|
|
cd3b12c579 | ||
|
|
0d6232ffef | ||
|
|
6da20f0efe | ||
|
|
8e25e83e5a | ||
|
|
fe188f8722 | ||
|
|
e4bc8b765b | ||
|
|
d28b6e9ac2 | ||
|
|
ac3cd1b183 | ||
|
|
c601d3b021 | ||
|
|
9bc2165e5c | ||
|
|
374d317412 | ||
|
|
e50c89834e | ||
|
|
f367c202d9 | ||
|
|
9d33d83bb4 | ||
|
|
8f1eff50aa | ||
|
|
e7918e25fd | ||
|
|
1eff84615c | ||
|
|
f1fd6ba3c0 | ||
|
|
39daec9bec | ||
|
|
484f5faf8d | ||
|
|
9a227d186f | ||
|
|
fc27061838 | ||
|
|
d71cc3be77 | ||
|
|
503e680672 | ||
|
|
1f77f4a4ed | ||
|
|
e8fa7daed5 | ||
|
|
936c73b54d | ||
|
|
b9cb4fc36a | ||
|
|
a808a02f05 | ||
|
|
e142a783f7 | ||
|
|
201536f265 | ||
|
|
0d42e76006 | ||
|
|
ca47949475 | ||
|
|
1c8e557eb4 | ||
|
|
11ca603490 | ||
|
|
85d8b07f09 | ||
|
|
d2ee536c16 | ||
|
|
e12e04f482 | ||
|
|
5743537945 | ||
|
|
18e22cd82e | ||
|
|
bdf019a9ac | ||
|
|
5cbafc57c0 | ||
|
|
55674b858b | ||
|
|
bb390f435c | ||
|
|
92b9eebab2 | ||
|
|
074413a96d | ||
|
|
4685ba8d3e | ||
|
|
d2c307ab30 | ||
|
|
a5193634d0 | ||
|
|
95725d121a | ||
|
|
56f2559798 | ||
|
|
38bffc9db1 | ||
|
|
1d3308d904 | ||
|
|
3c9e5adefa | ||
|
|
a065ad4ba7 | ||
|
|
048aec32e7 | ||
|
|
a258b17fac | ||
|
|
07facd2086 | ||
|
|
6bf342a861 | ||
|
|
2cca1ae860 | ||
|
|
becf5bf482 | ||
|
|
afe9e579d6 | ||
|
|
0b82fe60ea | ||
|
|
6fad330efc | ||
|
|
87f21a301d | ||
|
|
0123fed65d | ||
|
|
64ef85159d | ||
|
|
e63779d9e1 | ||
|
|
ac55c05c30 | ||
|
|
8fa672ed71 |
@@ -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.
|
||||
@@ -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
|
||||
---
|
||||
|
||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
"@opencode-ai/schema": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
"@opencode-ai/client": minor
|
||||
---
|
||||
|
||||
Remove the unused question request API and use session forms for question tool interactions.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Apply shared Session model-request preparation to transient generation.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
"@opencode-ai/schema": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
"@opencode-ai/client": minor
|
||||
---
|
||||
|
||||
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
|
||||
external directories, while the default tree-sitter path remains unchanged.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Make the experimental portable shell scanner authoritative, with no Tree-sitter
|
||||
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
|
||||
expressions, quoting, and substitutions natively. Preserve existing redirect and
|
||||
declaration permission matching, and make PowerShell saved approvals cover the
|
||||
original command spelling. Parser failures remain visible without changing the
|
||||
permission engine. The default Tree-sitter path is unchanged.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
---
|
||||
|
||||
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
|
||||
@@ -2,3 +2,4 @@ packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
packages/core/src/models-dev/snapshot.txt linguist-generated
|
||||
packages/core/src/**/*.txt text eol=lf
|
||||
packages/httpapi-codegen/test/generated/*.ts text eol=lf
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -426,7 +426,7 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -506,7 +506,7 @@
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1043,7 +1043,7 @@
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@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",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
@@ -1057,14 +1057,15 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.13",
|
||||
@@ -1085,7 +1086,7 @@
|
||||
"@solid-primitives/resize-observer": "2.2.0",
|
||||
"@solid-primitives/storage": "4.4.0",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/router": "1.0.0",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
@@ -1104,7 +1105,7 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1118,7 +1119,7 @@
|
||||
"resolve.exports": "2.0.3",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "4.4.3",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-js": "1.9.15",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-sonner": "0.3.1",
|
||||
"sst": "4.13.1",
|
||||
@@ -1663,13 +1664,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g=="],
|
||||
|
||||
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
|
||||
|
||||
@@ -2893,7 +2894,7 @@
|
||||
|
||||
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
|
||||
|
||||
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
|
||||
"@solidjs/router": ["@solidjs/router@1.0.0", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q=="],
|
||||
|
||||
"@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="],
|
||||
|
||||
@@ -3833,7 +3834,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -5235,9 +5236,9 @@
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@7.1.0", "", {}, "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw=="],
|
||||
|
||||
"seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
|
||||
"seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
||||
"seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
@@ -5299,7 +5300,7 @@
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
|
||||
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
|
||||
|
||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||
|
||||
@@ -6307,10 +6308,18 @@
|
||||
|
||||
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"@solidjs/start/seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
|
||||
|
||||
"@solidjs/start/seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
||||
|
||||
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
@@ -6891,6 +6900,8 @@
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||
|
||||
+4
-4
@@ -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-No3mCuG2tGQauX1HUpO+rebiWh+rrpSHUCC7rtXFu1s=",
|
||||
"aarch64-linux": "sha256-8joWv1iDkc6TejEukGBEX0wW8DPs55wJEPY7+9+HDdM=",
|
||||
"aarch64-darwin": "sha256-4MWGFQUIP1Ae4dujztTb4G8/uDJx2wiOoypcPSUWDbw=",
|
||||
"x86_64-darwin": "sha256-BtvnraCJmVagtA3Iv+EbWodjFG74sTd2Purqgo7Wkr4="
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -39,10 +39,10 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -76,7 +76,7 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -101,11 +101,11 @@
|
||||
"tailwindcss": "4.1.11",
|
||||
"vite": "7.3.6",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/router": "1.0.0",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@sentry/solid": "10.71.0",
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-js": "1.9.15",
|
||||
"solid-sonner": "0.3.1",
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"@lydell/node-pty": "1.2.0-beta.12"
|
||||
@@ -161,7 +161,8 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
@@ -169,7 +170,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.10": "patches/solid-js@1.9.10.patch",
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.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",
|
||||
|
||||
+29
-11
@@ -214,22 +214,40 @@ the requests sent by code under test:
|
||||
import { Effect } from "effect"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||
})
|
||||
|
||||
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||
const programWithTestClient = Effect.gen(function* () {
|
||||
const test = yield* TestLLM.Test
|
||||
yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
|
||||
const result = yield* program
|
||||
const test = yield* TestLLM.Service
|
||||
console.log(test.requests)
|
||||
console.log(yield* test.requests())
|
||||
return result
|
||||
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||
}).pipe(Effect.provide(TestLLM.testLayer()))
|
||||
```
|
||||
|
||||
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||
available on the yielded `TestLLM.Service`.
|
||||
`testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
|
||||
normal client; tests use the additional controls. Each layer build has fresh state.
|
||||
|
||||
- `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
|
||||
- `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
|
||||
- `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
|
||||
other without changing queued replies; queued replies take precedence.
|
||||
- `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
|
||||
`serve` receives the original canonical request.
|
||||
- `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
|
||||
- `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
|
||||
unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
|
||||
already provide Scope.
|
||||
|
||||
Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
|
||||
## Caching
|
||||
|
||||
|
||||
@@ -496,6 +496,7 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
@@ -574,6 +575,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
if (event.contentBlockDelta?.delta?.toolUse) {
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
if (state.finishedTools.has(index)) return [state, []] as const
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
@@ -612,6 +614,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
@@ -703,6 +706,7 @@ export const protocol = Protocol.make({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -391,17 +391,19 @@ export interface ParserState {
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<string>
|
||||
// Call ids stay independent of item ids, which may be omitted or reused.
|
||||
readonly completedTools: ReadonlySet<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly open: boolean
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
@@ -826,16 +828,16 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
|
||||
if (!event.delta || state.message?.id !== id) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const phase = state.message.phase
|
||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (state.messageItems.has(id)) {
|
||||
if (state.message?.id === id) {
|
||||
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
|
||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||
}
|
||||
@@ -843,21 +845,80 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
const decodeMessagePart = Schema.decodeUnknownOption(
|
||||
Schema.Union([OpenResponsesOutputText, Schema.Struct({ type: Schema.tag("refusal"), refusal: Schema.String })]),
|
||||
)
|
||||
|
||||
const decodeSummaryPart = Schema.decodeUnknownOption(OpenResponsesReasoningSummaryText)
|
||||
|
||||
const decodeReasoningPart = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ type: Schema.tag("reasoning_text"), text: Schema.String }),
|
||||
)
|
||||
|
||||
const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
if (!parts.some((part) => part !== undefined && part.length > 0)) return undefined
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] !== "concluded")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
lifecycle,
|
||||
events,
|
||||
`${itemID}:${index}`,
|
||||
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
|
||||
[itemID]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
|
||||
),
|
||||
),
|
||||
[index]: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item?.open) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
|
||||
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
|
||||
const current = started.reasoningItems[itemID]
|
||||
if (!current) return [started, emitted]
|
||||
const events: LLMEvent[] = [...emitted]
|
||||
return [
|
||||
{
|
||||
...started,
|
||||
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
|
||||
reasoningItems: {
|
||||
...started.reasoningItems,
|
||||
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
|
||||
},
|
||||
},
|
||||
events,
|
||||
@@ -869,7 +930,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
@@ -878,32 +939,48 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
|
||||
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// Responses APIs stream reasoning items in a stable order:
|
||||
// Responses APIs normally stream reasoning items in this order:
|
||||
// `output_item.added` (reasoning) →
|
||||
// `reasoning_summary_part.added` (index=0) →
|
||||
// `reasoning_summary_text.delta` →
|
||||
// `reasoning_summary_part.done` (index=0) →
|
||||
// (repeat for index>0) →
|
||||
// `output_item.done` (reasoning).
|
||||
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
|
||||
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
|
||||
// short-circuits when the entry already exists, and higher-index handlers
|
||||
// fold against the same entry. Behaviour for out-of-order events is
|
||||
// best-effort, not guaranteed.
|
||||
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
|
||||
// also an implicit boundary for the previous part. This keeps the common event
|
||||
// lifecycle ordered when a compatible provider omits or delays a part-done event.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
const phase = messagePhase(item.phase)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.reduce((lifecycle, id) => {
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
return Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
id,
|
||||
providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }),
|
||||
)
|
||||
}, state.lifecycle)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||
lifecycle,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
@@ -912,6 +989,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: true,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
@@ -923,6 +1001,8 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
const id = item.id ?? item.call_id
|
||||
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
|
||||
return [state, NO_EVENTS]
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
@@ -943,55 +1023,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (event.summary_index === 0) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (!item?.open) return [state, NO_EVENTS]
|
||||
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1041,28 +1080,33 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
|
||||
const item = event.item
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state: ParserState,
|
||||
item: Event["item"],
|
||||
) {
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of parts) {
|
||||
const decoded = Option.getOrUndefined(decodeMessagePart(part))
|
||||
if (!decoded) continue
|
||||
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
|
||||
}
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
messageItems.delete(item.id)
|
||||
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1070,20 +1114,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const id = item.id ?? item.call_id
|
||||
const tools = state.tools[id]
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
|
||||
})
|
||||
const callID = item.call_id
|
||||
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const fallback = item.id ?? callID
|
||||
// Match the pending tool by call id so item events that disagree on
|
||||
// whether `item.id` is present still resolve the same call.
|
||||
const registered =
|
||||
state.tools[fallback] !== undefined
|
||||
? fallback
|
||||
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
|
||||
const id = registered ?? fallback
|
||||
const tools =
|
||||
registered !== undefined
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: callID,
|
||||
name: item.name,
|
||||
providerMetadata: metadata,
|
||||
})
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const finished = result.events ?? []
|
||||
// A done-only call never streamed a start event, so open its lifecycle here.
|
||||
const resultEvents =
|
||||
registered !== undefined || finished.length === 0
|
||||
? finished
|
||||
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
@@ -1094,30 +1154,82 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall,
|
||||
tools: result.tools,
|
||||
completedTools: new Set([...state.completedTools, callID]),
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
for (const part of summaryParts) {
|
||||
const decoded = Option.getOrUndefined(decodeSummaryPart(part))
|
||||
// Keep missing entries so the array still matches the provider's summary indexes.
|
||||
summary.push(decoded?.text)
|
||||
}
|
||||
const reasoningParts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of reasoningParts) {
|
||||
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
|
||||
if (decoded) content.push(decoded.text)
|
||||
}
|
||||
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
|
||||
const events: LLMEvent[] = []
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
const fragments = Object.entries(reasoningItem.summaryParts)
|
||||
let lifecycle = state.lifecycle
|
||||
for (const [index, status] of fragments) {
|
||||
if (status === "concluded") continue
|
||||
// Do not repeat earlier summaries that were already emitted as separate fragments.
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "concluded" },
|
||||
deltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
@@ -1129,32 +1241,24 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
})
|
||||
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
const reconciled =
|
||||
event.type === "response.completed"
|
||||
? yield* Effect.reduce(
|
||||
event.response?.output ?? [],
|
||||
() => [state, NO_EVENTS] satisfies StepResult,
|
||||
([current, events], item) => {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (
|
||||
id === undefined ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
|
||||
)
|
||||
},
|
||||
)
|
||||
: ([state, NO_EVENTS] satisfies StepResult)
|
||||
const current = reconciled[0]
|
||||
let current = state
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
|
||||
events.push(...pending.events)
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
@@ -1256,6 +1360,13 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
if (
|
||||
event.item &&
|
||||
isReasoningItem(event.item) &&
|
||||
state.reasoningItems[event.item.id] === undefined &&
|
||||
state.lifecycle.reasoning.size > 0
|
||||
)
|
||||
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
|
||||
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
@@ -1273,7 +1384,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
return onOutputItemDone(state, event.item)
|
||||
}
|
||||
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`)
|
||||
@@ -1305,10 +1416,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
completedTools: new Set<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
message: undefined,
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
@@ -245,6 +246,8 @@ export const OpenAIChatEvent = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
const DONE = "[DONE]" as const
|
||||
const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)])
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
|
||||
interface PendingToolDelta {
|
||||
@@ -812,7 +815,12 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
default:
|
||||
return "unknown" as const
|
||||
return yield* new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: `Provider finish_reason: ${reason}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -997,33 +1005,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
// Reasoning is one response-wide channel: it stays open alongside text and
|
||||
// refusal output so late reasoning deltas and details join the same block,
|
||||
// and `finishEvents` closes it once with the complete metadata.
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
if (delta?.refusal) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
@@ -1072,17 +1059,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||
const incompleteTools = finishReason?.normalized === "content-filter" || finishReason?.normalized === "length"
|
||||
if (
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(pendingTools).length
|
||||
)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
// Filtering or truncation terminates the response without confirming pending tool calls.
|
||||
const finished =
|
||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
: undefined
|
||||
|
||||
@@ -1129,10 +1124,12 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
// Snapshot details at publish time so the emitted event never observes later
|
||||
// mutation of the accumulated `reasoningDetails` array.
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
@@ -1166,7 +1163,7 @@ export const protocol = Protocol.make({
|
||||
from: fromRequest,
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
event: OpenAIChatStreamEvent,
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
@@ -1180,12 +1177,14 @@ export const protocol = Protocol.make({
|
||||
nextToolIndex: 0,
|
||||
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
|
||||
}),
|
||||
step,
|
||||
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
|
||||
terminal: (event) => event === DONE,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
})
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
|
||||
export const framing = Framing.sseWithDone
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>().with({ framing })
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Route, type RouteRoutedLanguageModelInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import * as OpenAIChat from "./openai-chat.js"
|
||||
|
||||
const ADAPTER = "openai-compatible-chat"
|
||||
@@ -19,7 +18,7 @@ export const route = Route.make({
|
||||
providerMetadataKey: "openai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: Framing.sse,
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
|
||||
|
||||
@@ -207,15 +207,16 @@ export const errorText = (error: unknown) => {
|
||||
|
||||
/**
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* decoder, optionally filters named events, and drops empty / `[DONE]`
|
||||
* keep-alive events so the protocol event schema sees one JSON string per
|
||||
* element. Retry control events are ignored without interrupting the stream.
|
||||
* decoder, optionally filters named events, and drops empty events. `[DONE]`
|
||||
* is dropped by default or retained for protocols that use it as their stream
|
||||
* boundary. Retry control events are ignored without interrupting the stream.
|
||||
* Decoder failures become provider output errors so the public error channel
|
||||
* stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (
|
||||
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||
events?: ReadonlySet<string>,
|
||||
includeDone = false,
|
||||
): Stream.Stream<string, AIError> =>
|
||||
bytes.pipe(
|
||||
Stream.decodeText(),
|
||||
@@ -240,7 +241,7 @@ export const sseFraming = (
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
@@ -62,22 +62,31 @@ export const reasoningEnd = (
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text?: string,
|
||||
): State => {
|
||||
if (!state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }))
|
||||
const reasoning = new Set(stepped.reasoning)
|
||||
reasoning.delete(id)
|
||||
return { ...stepped, reasoning }
|
||||
}
|
||||
|
||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
export const textEnd = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text?: string,
|
||||
): State => {
|
||||
if (!state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
const text = new Set(stepped.text)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
events.push(LLMEvent.textEnd({ id, text, providerMetadata }))
|
||||
const open = new Set(stepped.text)
|
||||
open.delete(id)
|
||||
return { ...stepped, text: open }
|
||||
}
|
||||
|
||||
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
|
||||
|
||||
@@ -37,6 +37,7 @@ const patterns = [
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
/range of input length should be/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
/request_too_large/i,
|
||||
@@ -59,6 +60,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
@@ -74,7 +76,8 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -90,8 +93,12 @@ export interface ProviderFailure {
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}
|
||||
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
// Classification records affirmative evidence about a failure. Deterministic
|
||||
// failures need positive identification (a 4xx status, quota/auth/policy
|
||||
// signals); anything unrecognized stays UnknownProvider, which the session
|
||||
// retry policy treats as retry-eligible because transient failures arrive in
|
||||
// unpredictable shapes while deterministic rejections almost always carry a
|
||||
// status or known code.
|
||||
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 ?? ""
|
||||
@@ -116,46 +123,36 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
|
||||
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" })
|
||||
if (codes.includes("permission_error"))
|
||||
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
if (
|
||||
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
|
||||
input.status === 429 ||
|
||||
codes.some(
|
||||
(code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception",
|
||||
) ||
|
||||
RATE_LIMIT_TEXT.test(text)
|
||||
)
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (RATE_LIMIT_TEXT.test(text))
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (input.status === 429) {
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||
if (
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestError(details)
|
||||
// Any remaining 4xx is a deterministic rejection of this request.
|
||||
if (input.status !== undefined && input.status >= 400 && input.status < 500) return new InvalidRequestError(details)
|
||||
return new UnknownProviderError(details)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
@@ -75,7 +74,7 @@ export const route = Route.make({
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
framing: Framing.sse,
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
||||
@@ -167,7 +166,7 @@ export const route = Route.make({
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
framing: Framing.sse,
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -139,7 +139,7 @@ const toAIError = (error: AuthError): AIError => {
|
||||
return new AIError({
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
|
||||
? new AuthenticationError({ message: error.message, cause: error })
|
||||
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { AIError } from "../schema/index.js"
|
||||
* `Framing` is the byte-stream-shaped seam between transport and protocol:
|
||||
*
|
||||
* - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder,
|
||||
* drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
|
||||
* payload of one event.
|
||||
* and emit the `data:` payload of each non-empty event. The default drops
|
||||
* `[DONE]`; protocols that use it as a terminal select `sseWithDone`.
|
||||
* - AWS event stream — length-prefixed binary frames with CRC checksums.
|
||||
* Each emitted frame is one parsed binary event record.
|
||||
*
|
||||
@@ -26,6 +26,12 @@ export interface Definition<Frame> {
|
||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
|
||||
|
||||
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
|
||||
export const sseWithDone: Definition<string> = {
|
||||
id: "sse",
|
||||
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
|
||||
}
|
||||
|
||||
/** SSE framing restricted to protocol-recognized event names. */
|
||||
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
|
||||
id: "sse",
|
||||
|
||||
@@ -44,10 +44,7 @@ export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoR
|
||||
|
||||
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
|
||||
"Authentication",
|
||||
{
|
||||
...ReasonFields,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
},
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
|
||||
|
||||
@@ -112,6 +112,8 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
export const TextEnd = Schema.Struct({
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
@@ -134,6 +136,8 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
export const ReasoningEnd = Schema.Struct({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
@@ -328,17 +332,32 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
})
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
|
||||
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
|
||||
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
|
||||
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
|
||||
) => {
|
||||
const order: string[] = []
|
||||
const parts = new Map<string, string>()
|
||||
for (const event of events) {
|
||||
if (isDelta(event)) {
|
||||
if (!parts.has(event.id)) order.push(event.id)
|
||||
parts.set(event.id, (parts.get(event.id) ?? "") + event.text)
|
||||
}
|
||||
if (isEnd(event) && event.text !== undefined) {
|
||||
if (!parts.has(event.id)) order.push(event.id)
|
||||
parts.set(event.id, event.text)
|
||||
}
|
||||
}
|
||||
return order.map((id) => parts.get(id)).join("")
|
||||
}
|
||||
|
||||
const responseText = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events
|
||||
.filter(LLMEvent.is.textDelta)
|
||||
.map((event) => event.text)
|
||||
.join("")
|
||||
joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd)
|
||||
|
||||
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events
|
||||
.filter(LLMEvent.is.reasoningDelta)
|
||||
.map((event) => event.text)
|
||||
.join("")
|
||||
joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd)
|
||||
|
||||
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events.reduce<Usage | undefined>(
|
||||
@@ -445,10 +464,11 @@ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState
|
||||
const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
|
||||
const current = state.textParts[event.id]
|
||||
if (!current) return state
|
||||
const text = event.text ?? current.text
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,10 +498,11 @@ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): Resp
|
||||
const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
|
||||
const current = state.reasoningParts[event.id]
|
||||
if (!current) return state
|
||||
const text = event.text ?? current.text
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,12 +600,12 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
||||
usage: Schema.optional(Usage),
|
||||
finishReason: FinishReasonDetails,
|
||||
}) {
|
||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
|
||||
get text() {
|
||||
return responseText(this.events)
|
||||
}
|
||||
|
||||
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
|
||||
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
|
||||
get reasoning() {
|
||||
return responseReasoning(this.events)
|
||||
}
|
||||
|
||||
+103
-52
@@ -1,6 +1,6 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import { LLMClient, type Interface as LLMClientShape } from "./route/client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
@@ -16,13 +16,33 @@ export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
|
||||
|
||||
export type Responder = (request: LLMRequest) => Response
|
||||
|
||||
export interface TestInterface extends ClientInterface {
|
||||
/** Returns a snapshot of requests observed at execution time. */
|
||||
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
/** Replaces the fallback without changing queued responses. */
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
|
||||
readonly serve: (responder: Responder) => Effect.Effect<void>
|
||||
/** Waits for request arrivals, not output or completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
|
||||
|
||||
/** @deprecated Use TestInterface through Test and testLayer. */
|
||||
export interface Interface {
|
||||
readonly requests: LLMRequest[]
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
|
||||
readonly client: LLMClientShape
|
||||
readonly client: ClientInterface
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
@@ -31,6 +51,7 @@ export interface LayerOptions {
|
||||
readonly fallback?: Response
|
||||
}
|
||||
|
||||
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
@@ -80,59 +101,64 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback: Response | Responder | undefined = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const stream = ((request: LLMRequest) => {
|
||||
requests.push(options.transformRequest?.(request) ?? request)
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
const response = responses.shift() ?? fallback
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
|
||||
const streamed = toStream(response)
|
||||
const gate = activeGate
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
}) as LLMClientShape["stream"]
|
||||
const client = LLMClient.Service.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
requests,
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
const test = Test.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
wait,
|
||||
gate: Effect.gen(function* () {
|
||||
),
|
||||
requests: () => Effect.sync(() => [...requests]),
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
serve: (responder) =>
|
||||
Effect.sync(() => {
|
||||
fallback = responder
|
||||
}),
|
||||
wait,
|
||||
gate: () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = {
|
||||
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
|
||||
release: yield* Latch.make(),
|
||||
@@ -147,11 +173,36 @@ export const layer = (options: LayerOptions = {}) =>
|
||||
release,
|
||||
}
|
||||
}),
|
||||
client,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
return { test, requests }
|
||||
})
|
||||
|
||||
/** Provides one shared implementation under the normal client and test-control tags. */
|
||||
export const testLayer = (options: LayerOptions = {}) =>
|
||||
Layer.effectContext(
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test)),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Service.of({
|
||||
requests: implementation.requests,
|
||||
push: implementation.test.push,
|
||||
always: implementation.test.always,
|
||||
wait: implementation.test.wait,
|
||||
gate: implementation.test.gate(),
|
||||
client: implementation.test,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated testLayer provides LLMClient.Service directly. */
|
||||
export const clientLayer = Layer.effect(
|
||||
LLMClient.Service,
|
||||
Effect.map(Service, (service) => service.client),
|
||||
|
||||
@@ -574,6 +574,7 @@ describe("WebSocket channel execution", () => {
|
||||
const model = 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_item.added", item: { type: "message", id: "msg_1" } }),
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
@@ -32,6 +32,8 @@ describe("public exports", () => {
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
expect(TestLLM.testLayer).toBeFunction()
|
||||
expect(TestLLM.Test.of).toBeFunction()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
Vendored
+1
-1
@@ -24,7 +24,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -41,7 +41,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -11,6 +11,7 @@ describe("provider error classification", () => {
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
"Range of input length should be [1, 129024]",
|
||||
"Too many tokens",
|
||||
"Token limit exceeded",
|
||||
]
|
||||
@@ -80,6 +81,46 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies retryable server messages as provider internal", () => {
|
||||
const message =
|
||||
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing."
|
||||
|
||||
expect(
|
||||
[
|
||||
message,
|
||||
"Try again",
|
||||
"Please retry your request shortly.",
|
||||
"You can retry the request.",
|
||||
"Try your request again.",
|
||||
"The service is temporarily at capacity.",
|
||||
"The model is overloaded.",
|
||||
"Service unavailable",
|
||||
"Internal server error",
|
||||
"The server is busy.",
|
||||
"Provider returned error",
|
||||
"Provider returned an error",
|
||||
"ResourceExhausted",
|
||||
"Upstream connection failed",
|
||||
"Exceeded request buffer limit while retrying upstream",
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(Array(15).fill("ProviderInternal"))
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Provider request failed", rawBody: "Please try again later." })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("prioritizes specific failures over retryable server text", () => {
|
||||
expect(
|
||||
[
|
||||
classifyProviderFailure({ message: "Invalid credentials, try again", status: 401 }),
|
||||
classifyProviderFailure({ message: "Quota exceeded, try again", status: 429 }),
|
||||
classifyProviderFailure({ message: "Rate limit exceeded, try again" }),
|
||||
classifyProviderFailure({ message: "Upstream request failed: validation failed", status: 400 }),
|
||||
classifyProviderFailure({ message: "Try again", status: 200 }),
|
||||
].map((failure) => failure._tag),
|
||||
).toEqual(["Authentication", "QuotaExceeded", "RateLimit", "InvalidRequest", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
@@ -87,10 +128,12 @@ describe("provider error classification", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies network error text as provider internal", () => {
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
@@ -103,10 +146,11 @@ describe("provider error classification", () => {
|
||||
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
|
||||
})
|
||||
|
||||
test("keeps unknown and malformed provider payloads non-retryable", () => {
|
||||
test("leaves unrecognized failures unclassified for the retry default", () => {
|
||||
expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { model } from "@opencode-ai/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
@@ -36,7 +38,8 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
expect(modules[19].model).toBe(modules[21].model)
|
||||
expect(modules[19].model).not.toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
@@ -139,8 +142,10 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
path: "/messages",
|
||||
})
|
||||
expect(
|
||||
Endpoint.render(selected.route.endpoint, { request: LLM.request({ model: selected }), body: {} }).toString(),
|
||||
).toBe("https://messages.example.test/v1/messages")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
||||
|
||||
@@ -491,6 +491,59 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores late tool deltas after contentBlockStop", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
contentBlockIndex: 0,
|
||||
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||
},
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"weather"}' } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"late":true}' } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.toolCalls).toEqual([
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool deltas without contentBlockStart", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Bedrock Converse tool delta is missing its tool call",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers incomplete tool input at finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("provider error retention", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.message).toContain("Slow down")
|
||||
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
|
||||
expect(error.reason._tag).toBe("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/")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.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"
|
||||
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
const generate = (...events: OpenResponses.Event[]) =>
|
||||
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
|
||||
|
||||
describe("Open Responses completed item text", () => {
|
||||
;["Draft expanded", "D", "Replacement", ""].forEach((text) => {
|
||||
it.effect(`replaces streamed text with completed item text ${JSON.stringify(text)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", phase: "final_answer", content: [{ type: "output_text", text }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe(text)
|
||||
expect(response.events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Draft"])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text,
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("joins completed text and refusal parts without streamed text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [
|
||||
{ type: "output_text", text: "Answer. " },
|
||||
{ type: "refusal", refusal: "Cannot help." },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Answer. Cannot help.")
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not create an empty text fragment for an empty completed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.message.content).toEqual([])
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
;[
|
||||
{
|
||||
name: "summary",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Final" },
|
||||
{ type: "summary_text", text: "summary" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Final\n\nsummary",
|
||||
},
|
||||
{
|
||||
name: "raw text",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Raw",
|
||||
},
|
||||
{
|
||||
name: "streamed fallback",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [
|
||||
{ type: "reasoning_text", text: "" },
|
||||
{ type: "reasoning_text", text: "" },
|
||||
],
|
||||
text: "Draft",
|
||||
},
|
||||
].forEach((fixture) => {
|
||||
it.effect(`uses ${fixture.name} at item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: fixture.summary,
|
||||
content: fixture.content,
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe(fixture.text)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("replaces only the still-open summary without repeating earlier text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First " },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First " },
|
||||
{ type: "summary_text", text: "final" },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe("First final")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
;["response.completed", "response.incomplete"].forEach((type) => {
|
||||
it.effect(`keeps streamed text when part finals are followed by ${type} without item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "Hel" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 1, delta: "world" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Hello " },
|
||||
{
|
||||
type: "response.content_part.done",
|
||||
item_id: "msg_1",
|
||||
content_index: 0,
|
||||
part: { type: "output_text", text: "Hello " },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.reasoning_summary_part.done",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "Part final" },
|
||||
},
|
||||
{
|
||||
type,
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: type === "response.incomplete" ? { reason: "max_output_tokens" } : undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(response.text).toBe("Helworld")
|
||||
expect(response.reasoning).toBe("Draft")
|
||||
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,465 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.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"
|
||||
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
|
||||
const collect = (...input: OpenResponses.Event[]) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* LLMClient.stream(request).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.provide(fixedResponse(sseEvents(...input))),
|
||||
)
|
||||
expectLifecycle(
|
||||
events,
|
||||
input.some((event) => event.type === "response.completed"),
|
||||
)
|
||||
return events
|
||||
})
|
||||
|
||||
// Deliberately local to these basic-item fixtures, not a general stream validator.
|
||||
function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
const active = { text: new Set<string>(), reasoning: new Set<string>() }
|
||||
const tools = new Map<string, "started" | "ended" | "called">()
|
||||
events.forEach((event) => {
|
||||
if (event.type === "text-start" || event.type === "reasoning-start") {
|
||||
const blocks = event.type === "text-start" ? active.text : active.reasoning
|
||||
expect(blocks.size).toBe(0)
|
||||
blocks.add(event.id)
|
||||
}
|
||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||
expect((event.type === "text-delta" ? active.text : active.reasoning).has(event.id)).toBe(true)
|
||||
}
|
||||
if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||
expect((event.type === "text-end" ? active.text : active.reasoning).delete(event.id)).toBe(true)
|
||||
}
|
||||
if (event.type === "tool-input-start") {
|
||||
expect(tools.has(event.id)).toBe(false)
|
||||
tools.set(event.id, "started")
|
||||
}
|
||||
if (event.type === "tool-input-delta") expect(tools.get(event.id)).toBe("started")
|
||||
if (event.type === "tool-input-end") {
|
||||
expect(tools.get(event.id)).toBe("started")
|
||||
tools.set(event.id, "ended")
|
||||
}
|
||||
if (event.type === "tool-call") {
|
||||
expect(tools.get(event.id)).toBe("ended")
|
||||
tools.set(event.id, "called")
|
||||
}
|
||||
// Incomplete responses may leave pending tool inputs without a call.
|
||||
if (event.type === "finish" && completed) {
|
||||
expect(active.text.size).toBe(0)
|
||||
expect(active.reasoning.size).toBe(0)
|
||||
expect([...tools.values()].every((status) => status === "called")).toBe(true)
|
||||
}
|
||||
})
|
||||
expect(events.filter(LLMEvent.is.stepStart)).toHaveLength(1)
|
||||
expect(events[0]?.type).toBe("step-start")
|
||||
expect(events.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
|
||||
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
expect(events.slice(-2).map((event) => event.type)).toEqual(["step-finish", "finish"])
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
|
||||
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
|
||||
// The third part omits both explicit summary boundaries.
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 0,
|
||||
item_id: "wrong",
|
||||
summary_index: 2,
|
||||
delta: "Third",
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Not streamed" }],
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
|
||||
completed,
|
||||
// Route termination must also prevent events after response completion.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
text: "Not streamed",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forgets never-streamed messages at implicit boundaries and preserves refusal phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_empty" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Checking" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Duplicate" },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_2", phase: null } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_empty", delta: "stale" },
|
||||
{ type: "response.output_text.done", item_id: "msg_empty", text: "stale final" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "late" },
|
||||
{ type: "response.refusal.delta", item_id: "msg_2", delta: "Cannot help." },
|
||||
{ type: "response.refusal.done", item_id: "msg_2", refusal: "Cannot help." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_2", phase: "final_answer" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_3", phase: null } },
|
||||
{ type: "response.refusal.done", item_id: "msg_3", refusal: "Done-only refusal." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_3" } },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_1", text: "Checking" },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: null } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_2", text: "Cannot help." },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_3",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_3", text: "Done-only refusal." },
|
||||
{ type: "text-end", id: "msg_3", providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
|
||||
{ type: "response.output_item.added", item },
|
||||
completed,
|
||||
)
|
||||
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
|
||||
const first = { ...item, ...(id === undefined ? {} : { id }) }
|
||||
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: first },
|
||||
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
|
||||
{ type: "response.output_item.added", item: duplicate },
|
||||
{ type: "response.output_item.done", item: duplicate },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
{ type: "response.output_item.added", item: duplicate },
|
||||
completed,
|
||||
)
|
||||
// Identity metadata comes from the first admission, not the duplicate.
|
||||
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"draft"}' },
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
|
||||
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: '{"query":"final"}' },
|
||||
{ type: "function_call", id: "fc_unseen", call_id: "call_unseen", name: "lookup", arguments: "{}" },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(events.slice(5, -2)).toEqual([
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "final" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves call identity and pending order when an item id is reused", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: first },
|
||||
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "" } },
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "", delta: "Thinking" },
|
||||
{ type: "response.output_text.delta", item_id: "", delta: "Answer" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "", encrypted_content: "state" } },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "" } },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual(["Thinking"])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Answer"])
|
||||
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: ":0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover a completed tool from a tracked message with the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "item_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "item_1", delta: "Answer" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "{}" }],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(events.filter(LLMEvent.is.finish).map((event) => event.reason.normalized)).toEqual(["stop"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flushes pending calls and open text when completed output is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "final_answer" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
// Generic terminal closure does not repeat the message's phase metadata.
|
||||
expect(events.slice(4, -2)).toEqual([
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not reconcile pending calls or terminal reasoning metadata on incomplete responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Partial" },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":' },
|
||||
{
|
||||
type: "response.incomplete",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: { reason: "max_output_tokens" },
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", encrypted_content: "not-reconciled" },
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"not-reconciled"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "length", raw: "max_output_tokens" },
|
||||
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
LanguageModel,
|
||||
ToolCallPart,
|
||||
@@ -807,6 +808,28 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finishes at the done sentinel without waiting for response EOF", () =>
|
||||
Effect.gen(function* () {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "stop"))),
|
||||
)
|
||||
},
|
||||
})
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(stream, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events.at(-1)?.type).toBe("finish")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves streamed refusals as ordinary assistant text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1127,7 +1150,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scalar reasoning after content starts", () =>
|
||||
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1144,8 +1167,35 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detailscalar")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
@@ -1191,7 +1241,18 @@ describe("OpenAI Chat route", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
// Snapshot reasoning-end metadata as each event is published so the
|
||||
// assertion cannot pass through later mutation of a shared array.
|
||||
const publishedEndMetadata: unknown[] = []
|
||||
const response = yield* LLMClient.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (LLMEvent.is.reasoningEnd(event))
|
||||
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
|
||||
}),
|
||||
),
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.map((state) => LLMResponse.complete(state)!),
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -1212,10 +1273,12 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
// Reasoning stays open alongside text and closes once during finalization.
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
@@ -1397,6 +1460,162 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when content is filtered", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "content_filter"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter", raw: "content_filter" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "content-filter", raw: "content_filter" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "content_filter"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when output is truncated", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "length"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "length", raw: "length" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "length", raw: "length" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "length"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "length", raw: "length" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unknown finish reasons without finalizing streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "future_reason"),
|
||||
)
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse(body)),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(yield* Ref.get(events)).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores empty identity fields on later tool call deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -405,6 +405,19 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores events after the done sentinel", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = `${sseEvents(
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
)}data: ${JSON.stringify(deltaChunk({ content: " late" }))}\n\n`
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts nullable usage and preserves provider fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -541,6 +554,19 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
|
||||
const unknown = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(unknown).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(decodeJson(unknown.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "future_reason" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -568,17 +594,13 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
|
||||
it.effect("preserves content-filter finishes in the common reason algebra", () =>
|
||||
Effect.gen(function* () {
|
||||
const filtered = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
|
||||
)
|
||||
const future = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
)
|
||||
|
||||
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("Open Responses-compatible route", () => {
|
||||
})
|
||||
|
||||
routings.forEach((routing) => {
|
||||
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
|
||||
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const address = { item_id: routing.item_id, output_index: routing.output_index }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -444,21 +444,18 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
expect.objectContaining({
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: `${routing.id}:0`,
|
||||
text: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ type: "reasoning-end", id: `${routing.id}:1` },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -671,7 +668,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -697,8 +694,9 @@ describe("Open Responses-compatible route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_raw:0",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2554,7 +2554,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2595,29 +2595,13 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Checked the diff.")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "terminal-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2644,7 +2628,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
|
||||
it.effect("recovers pending function calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2682,14 +2666,15 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.toolCall),
|
||||
expect(response.events.findIndex(LLMEvent.is.toolCall)).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "reasoning-end"),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
@@ -2750,6 +2735,227 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concludes reasoning at implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
// The next part is enough to conclude the previous one even when
|
||||
// its done event is delayed.
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
// Some compatible providers begin the next part with its first delta.
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecondThird")
|
||||
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_2" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("started reasoning before the previous item ended")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concludes text at implicit message boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
// An item that never streams text is untracked at the boundary too.
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_0" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
|
||||
// The previous message's done event is missing; the next message
|
||||
// item is the boundary for its open text.
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
|
||||
// Late deltas for concluded or untracked messages must stay no-ops.
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: " late" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_0", delta: " stale" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("FirstSecond")
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toMatchObject([
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("opens the tool lifecycle for a done-only function call", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
// No output_item.added: the call arrives only as a completed item.
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("tool-"))).toMatchObject([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores duplicate item boundary events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
// Duplicate added for a known item is not overlap and must no-op.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"weather"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
// Duplicates that drop the item id still resolve the same call.
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Think")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
expect(response.events.filter((event) => event.type === "tool-input-start")).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -2798,6 +3004,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -305,9 +305,11 @@ describe("OpenRouter", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -335,7 +337,14 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -361,7 +370,14 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "AB",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -376,7 +392,16 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: undefined,
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: undefined,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -104,6 +104,60 @@ describe("LLMResponse reducer", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("authoritative text-end value replaces accumulated deltas", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
|
||||
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
|
||||
expect(response?.text).toBe("Hello!")
|
||||
})
|
||||
|
||||
test("text-end without value keeps joined deltas", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "lo" }),
|
||||
LLMEvent.textEnd({ id: "t1" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([{ type: "text", text: "Hello" }])
|
||||
expect(response?.text).toBe("Hello")
|
||||
})
|
||||
|
||||
test("authoritative reasoning-end value replaces only its own fragment", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.reasoningStart({ id: "r1:0" }),
|
||||
LLMEvent.reasoningDelta({ id: "r1:0", text: "First summ" }),
|
||||
LLMEvent.reasoningEnd({ id: "r1:0", text: "First summary." }),
|
||||
LLMEvent.reasoningStart({ id: "r1:1" }),
|
||||
LLMEvent.reasoningDelta({ id: "r1:1", text: "Second summary." }),
|
||||
LLMEvent.reasoningEnd({ id: "r1:1" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([
|
||||
{ type: "reasoning", text: "First summary." },
|
||||
{ type: "reasoning", text: "Second summary." },
|
||||
])
|
||||
expect(response?.reasoning).toBe("First summary.Second summary.")
|
||||
})
|
||||
|
||||
test("end value recovers a fragment that streamed no deltas", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
|
||||
expect(response?.text).toBe("Hello!")
|
||||
})
|
||||
|
||||
test("clears malformed tool input without appending an executable call", () => {
|
||||
const state = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
|
||||
@@ -250,7 +250,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
}),
|
||||
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
|
||||
new AuthenticationError({ message: "Missing credentials" }),
|
||||
new RateLimitError({ message: "Rate limited" }),
|
||||
new QuotaExceededError({ message: "Quota exceeded" }),
|
||||
new ContentPolicyError({ message: "Content blocked" }),
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }),
|
||||
prompt: "Say hello",
|
||||
})
|
||||
const legacy = testEffect(TestLLM.layer())
|
||||
const it = testEffect(TestLLM.testLayer())
|
||||
|
||||
describe("TestLLM legacy client", () => {
|
||||
legacy.effect("does not observe requests or consume responses until execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
|
||||
llm.client.stream(request)
|
||||
llm.client.generate(request)
|
||||
expect(llm.requests).toEqual([])
|
||||
|
||||
expect((yield* llm.client.generate(request)).text).toBe("first")
|
||||
expect((yield* llm.client.generate(request)).text).toBe("second")
|
||||
expect(llm.requests).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("assigns and records a fresh response for each execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(
|
||||
TestLLM.text("first", "first"),
|
||||
TestLLM.text("second", "second"),
|
||||
TestLLM.text("third", "third"),
|
||||
TestLLM.text("fourth", "fourth"),
|
||||
)
|
||||
const stream = llm.client.stream(request)
|
||||
const generate = llm.client.generate(request)
|
||||
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("first", "first"))
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("second", "second"))
|
||||
expect((yield* generate).text).toBe("third")
|
||||
expect((yield* generate).text).toBe("fourth")
|
||||
expect(llm.requests).toEqual([request, request, request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("keeps module-level controls and clientLayer on the same backing state", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const requests = llm.requests
|
||||
yield* TestLLM.push(TestLLM.text("queued", "queued"))
|
||||
yield* TestLLM.always(TestLLM.text("fallback", "fallback"))
|
||||
expect((yield* LLMClient.generate(request).pipe(Effect.provide(TestLLM.clientLayer))).text).toBe("queued")
|
||||
yield* TestLLM.wait(1)
|
||||
expect(requests).toEqual([request])
|
||||
requests.length = 0
|
||||
expect((yield* llm.client.generate(request)).text).toBe("fallback")
|
||||
expect(llm.requests).toBe(requests)
|
||||
expect(requests).toEqual([request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const client = yield* LLMClient.Service
|
||||
expect(client).toBe(llm)
|
||||
const before = yield* llm.requests()
|
||||
yield* llm.push(TestLLM.text("hello", "answer"))
|
||||
const generate = client.generate(request)
|
||||
client.stream(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
|
||||
expect((yield* generate).text).toBe("hello")
|
||||
expect(before).toEqual([])
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
expect(yield* llm.requests()).not.toBe(yield* llm.requests())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prioritizes queued replies over request-dependent and constant fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const served: LLMRequest[] = []
|
||||
yield* llm.always(TestLLM.text("old fallback", "old"))
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
yield* llm.serve((request) => {
|
||||
served.push(request)
|
||||
return TestLLM.text(request.promptCacheKey ?? "default", "served")
|
||||
})
|
||||
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("first")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("second")
|
||||
expect(served).toEqual([])
|
||||
const selected = LLMRequest.update(request, { promptCacheKey: "selected" })
|
||||
expect((yield* LLMClient.generate(selected)).text).toBe("selected")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("default")
|
||||
expect(served).toEqual([selected, request])
|
||||
|
||||
yield* llm.push(TestLLM.text("queued again", "queued"))
|
||||
yield* llm.always(TestLLM.text("constant", "constant"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("queued again")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect(served).toEqual([selected, request])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
TestLLM.testLayer({
|
||||
transformRequest: (request) => LLMRequest.update(request, { promptCacheKey: "observation" }),
|
||||
}),
|
||||
).effect("transforms observations without changing the request passed to the responder", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.serve((input) => {
|
||||
expect(input).toBe(request)
|
||||
return TestLLM.text("original", "answer")
|
||||
})
|
||||
const generate = llm.generate(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
expect((yield* generate).text).toBe("original")
|
||||
expect(yield* llm.requests()).toEqual([LLMRequest.update(request, { promptCacheKey: "observation" })])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("broadcasts request-arrival waits and satisfies waits registered afterward", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const first = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* llm.generate(request)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* llm.generate(request)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
yield* llm.wait(2)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
;(["queued", "served"] as const).forEach((mode) => {
|
||||
it.effect(`assigns ${mode} replies before resuming request-arrival continuations`, () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const responses = [TestLLM.text("first", "first"), TestLLM.text("second", "second")]
|
||||
yield* mode === "queued" ? llm.push(...responses) : llm.serve(() => responses.shift() ?? [])
|
||||
const later = yield* llm
|
||||
.wait(1)
|
||||
.pipe(Effect.andThen(llm.generate(request)), Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
expect((yield* llm.generate(request)).text).toBe("first")
|
||||
expect((yield* Fiber.join(later)).text).toBe("second")
|
||||
expect(yield* llm.requests()).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("notifies arrival waiters even when the responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
const waiter = yield* llm.wait(1).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* Fiber.join(waiter)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds independent state even when the same layer is provided concurrently", () => {
|
||||
const layer = TestLLM.testLayer()
|
||||
const run = Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
yield* llm.push(TestLLM.text("one", "answer"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("one")
|
||||
return yield* llm.requests()
|
||||
}).pipe(Effect.provide(layer))
|
||||
return Effect.gen(function* () {
|
||||
expect(yield* Effect.all([run, run], { concurrency: "unbounded" })).toEqual([[request], [request]])
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("counts concurrent starts on one gate without serializing their response assignment", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
const generate = llm.generate(request)
|
||||
const gate = yield* llm.gate()
|
||||
const first = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
const second = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* llm.wait(2)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect((yield* Fiber.join(first)).text).toBe("first")
|
||||
expect((yield* Fiber.join(second)).text).toBe("second")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not clear a replacement gate when the previous gate is released", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const previous = yield* llm.gate()
|
||||
const first = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* previous.started
|
||||
const next = yield* llm.gate()
|
||||
yield* previous.release
|
||||
yield* Fiber.join(first)
|
||||
|
||||
const second = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* next.started
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* next.release
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases a gate when its deliberately narrower scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
// Only the gate is scoped here; its release must happen before the test ends.
|
||||
const run = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
return run
|
||||
}),
|
||||
)
|
||||
yield* Fiber.join(run)
|
||||
yield* llm.generate(request)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an executed response consumed after interruption and permits later requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("interrupted", "first"), TestLLM.text("next", "second"))
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* Fiber.interrupt(run)
|
||||
yield* gate.release
|
||||
expect((yield* llm.generate(request)).text).toBe("next")
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("consumes a supplied stream's post-finish tail and runs its finalizer", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const tail = yield* Latch.make()
|
||||
const release = yield* Latch.make()
|
||||
const finalized = yield* Latch.make()
|
||||
yield* llm.push(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => finalized.open)
|
||||
return Stream.fromIterable(TestLLM.text("complete", "answer")).pipe(
|
||||
Stream.concat(Stream.fromEffect(tail.open.pipe(Effect.andThen(release.await))).pipe(Stream.drain)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* tail.await
|
||||
expect(run.pollUnsafe()).toBeUndefined()
|
||||
yield* release.open
|
||||
expect((yield* Fiber.join(run)).text).toBe("complete")
|
||||
yield* finalized.await
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves irregular events, ordinary EOF, typed failures, and responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const events = [LLMEvent.textDelta({ id: "without-start", text: "partial" })]
|
||||
yield* llm.push(events, [])
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual(events)
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual([])
|
||||
|
||||
const failure = new AIError({ reason: new RateLimitError({ message: "Try later" }) })
|
||||
const observed: LLMEvent[] = []
|
||||
yield* llm.serve(() => TestLLM.failAfter(failure, ...events))
|
||||
expect(
|
||||
yield* llm.stream(request).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => observed.push(event))),
|
||||
Effect.flip,
|
||||
),
|
||||
).toBe(failure)
|
||||
expect(observed).toEqual(events)
|
||||
expect(yield* llm.generate(request).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* llm.push(TestLLM.text("recovered", "answer"))
|
||||
expect((yield* llm.generate(request)).text).toBe("recovered")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defects on unexpected requests instead of waiting for a late script", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("TestLLM has no response for request 1")
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
yield* llm.push(TestLLM.stop())
|
||||
yield* llm.generate(request)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/performance/results/
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
Element.prototype.replaceChildren = function (this: Element, ...nodes) {
|
||||
// The ref can run before data-component is assigned, so count on every target.
|
||||
this.setAttribute("data-test-replacements", String(Number(this.getAttribute("data-test-replacements")) + 1))
|
||||
return replace.apply(this, nodes)
|
||||
}
|
||||
})
|
||||
const component = await mount("opencode-composer-flow--failed-submission-restoration")
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
await expect(input).toHaveAttribute("data-test-replacements", "1")
|
||||
|
||||
await input.press("Home")
|
||||
await input.press("Shift+ArrowRight")
|
||||
await input.pressSequentially("XY")
|
||||
await expect(input).toHaveText("XYreserve this draft on failure")
|
||||
await expect(input).toHaveAttribute("data-test-replacements", "1")
|
||||
|
||||
// Closing the model picker restores the controller's saved caret through its editor ref.
|
||||
await component.locator('[data-action="composer-model"]').click()
|
||||
await page.getByRole("menu").getByRole("textbox").press("Escape")
|
||||
await expect(input).toBeFocused()
|
||||
await input.pressSequentially("!")
|
||||
await expect(input).toHaveText("XY!reserve this draft on failure")
|
||||
await component.getByRole("button", { name: "Send", exact: true }).click()
|
||||
await expect(component.getByRole("status")).toHaveText("Submission failed; draft restored")
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
|
||||
|
||||
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
|
||||
const host = document.createElement("main")
|
||||
host.dataset.testid = "timeline-virtualizer-fixture"
|
||||
host.dataset.scrolls = "0"
|
||||
host.dataset.viewportResizes = "0"
|
||||
host.style.cssText = "position:fixed;top:24px;right:24px;width:400px;z-index:1000"
|
||||
document.body.appendChild(host)
|
||||
|
||||
function Fixture() {
|
||||
const [state, setState] = createStore({ pinned: true, ready: false })
|
||||
const rows = Array.from(
|
||||
{ length: input.count },
|
||||
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
|
||||
)
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
|
||||
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
|
||||
let viewport!: HTMLDivElement
|
||||
let content!: HTMLDivElement
|
||||
let container!: HTMLDivElement
|
||||
const timeline = createTimelineVirtualizer({
|
||||
sessionKey: () => "cold-reveal-fixture",
|
||||
projection: {
|
||||
rows: () => rows,
|
||||
rowByKey: () => rowByKey,
|
||||
activeMessageID: () => undefined,
|
||||
messageRowIndex: () => indexes,
|
||||
messageLastRowIndex: () => indexes,
|
||||
},
|
||||
showHeader: () => false,
|
||||
pinned: () => state.pinned,
|
||||
scroll: () => ({ overflow: false, jump: false }),
|
||||
setScrollRef: (element) => {
|
||||
if (!element) return
|
||||
viewport = element
|
||||
resize.observe(element, { box: "border-box" })
|
||||
},
|
||||
setContentRef: (element) => {
|
||||
content = element
|
||||
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
|
||||
},
|
||||
onPin: () => setState("pinned", true),
|
||||
onUnpin: () => setState("pinned", false),
|
||||
onScheduleScrollState: (element) => {
|
||||
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
|
||||
host.dataset.lastScrollTop = String(element.scrollTop)
|
||||
},
|
||||
onResumeScroll: () => {},
|
||||
onSelectionInteraction: () => {},
|
||||
onUserScroll: () => {},
|
||||
onHistoryScroll: () => {},
|
||||
canRenderImmediately: () => input.immediate ?? false,
|
||||
})
|
||||
|
||||
const resize = new ResizeObserver((entries) => {
|
||||
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
|
||||
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
|
||||
})
|
||||
const reveal = new MutationObserver(() => {
|
||||
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
|
||||
// Capture the first reveal, not a later frame after geometry has recovered.
|
||||
const mounted = [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
host.dataset.firstReveal = JSON.stringify({
|
||||
rows: mounted.map((element) => Number(element.firstElementChild!.getAttribute("data-index"))),
|
||||
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
scrollTop: viewport.scrollTop,
|
||||
clipped: mounted
|
||||
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
|
||||
.map((element) => element.dataset.timelineKey),
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
resize.disconnect()
|
||||
reveal.disconnect()
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-testid="timeline-controls" data-pinned={state.pinned}>
|
||||
<button type="button" onClick={() => setState("ready", true)}>
|
||||
Complete Markdown
|
||||
</button>
|
||||
<button type="button" onClick={() => (container.style.display = "none")}>
|
||||
Hide viewport
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const parent = viewport.parentElement!
|
||||
host.dataset.scrolls = "0"
|
||||
// Keep the same scroller and complete Markdown while it has no layout box.
|
||||
viewport.remove()
|
||||
viewport.scrollTop = 0
|
||||
setState("ready", true)
|
||||
parent.prepend(viewport)
|
||||
container.style.removeProperty("display")
|
||||
}}
|
||||
>
|
||||
Reconnect ready rows
|
||||
</button>
|
||||
<div ref={container} style={{ height: "180px", width: "400px" }}>
|
||||
<timeline.View
|
||||
header={null}
|
||||
workspaceSession={() => false}
|
||||
deferred={() => false}
|
||||
renderRow={(row) => (
|
||||
<div
|
||||
data-component="markdown"
|
||||
data-markdown-ready={state.ready ? "" : undefined}
|
||||
style={{ height: `${input.rowHeight}px` }}
|
||||
>
|
||||
{row().userMessageID}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<Fixture />
|
||||
</LanguageProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 100, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(root).toHaveAttribute("data-observed-height", "180")
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(4)
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
|
||||
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
|
||||
rows: [96, 97, 98, 99],
|
||||
pendingMarkdown: 0,
|
||||
clipped: [],
|
||||
viewportHeight: 180,
|
||||
})
|
||||
})
|
||||
|
||||
for (const input of [
|
||||
{ name: "offset-only", count: 1, rowHeight: 600 },
|
||||
{ name: "zero-height", count: 4, rowHeight: 60 },
|
||||
]) {
|
||||
story(`reveals ready measured rows after an ${input.name} reconnect`, async ({ page }) => {
|
||||
await page.evaluate(
|
||||
async ({ fixture, input }) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer(input)
|
||||
},
|
||||
{ fixture, input },
|
||||
)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(root).toHaveAttribute("data-observed-height", "180")
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(1)
|
||||
|
||||
if (input.name === "offset-only") {
|
||||
await expect(root).toHaveAttribute("data-last-scroll-top", "484")
|
||||
await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 })
|
||||
await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
|
||||
}
|
||||
if (input.name === "zero-height") {
|
||||
await root.getByRole("button", { name: "Hide viewport", exact: true }).click()
|
||||
// Wait for ResizeObserver to clear the actual range, not just for display:none.
|
||||
await expect(root).toHaveAttribute("data-observed-height", "0")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(0)
|
||||
}
|
||||
await expect(root).not.toHaveAttribute("data-first-reveal")
|
||||
const resizes = await root.getAttribute("data-viewport-resizes")
|
||||
await root.getByRole("button", { name: "Reconnect ready rows", exact: true }).click()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
|
||||
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
|
||||
rows: input.count === 1 ? [0] : [0, 1, 2, 3],
|
||||
pendingMarkdown: 0,
|
||||
clipped: [],
|
||||
viewportHeight: 180,
|
||||
...(input.name === "offset-only" ? { scrollTop: 0 } : {}),
|
||||
})
|
||||
if (input.name === "offset-only") {
|
||||
// This repair must not depend on another native scroll or resize delivery.
|
||||
await expect(root).toHaveAttribute("data-scrolls", "0")
|
||||
await expect(root).toHaveAttribute("data-viewport-resizes", resizes!)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ The fixture requires every benchmark to call `report()`, automatically names and
|
||||
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
||||
```
|
||||
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, optional trace path, and trace scope before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide; the default window is page lifetime. Tab-switch traces begin after scenario setup and include explicit interaction markers. Scenario metrics use their own narrower observation windows.
|
||||
|
||||
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
||||
|
||||
@@ -81,13 +81,60 @@ Committed smoke and regression tests continue to own correctness coverage for pa
|
||||
|
||||
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.
|
||||
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=20` for a baseline distribution. Warm scenarios prepare the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
|
||||
|
||||
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
|
||||
|
||||
Cold means the destination transcript has never rendered in that fresh browser context. Warm means its complex answer was rendered and ready before switching away and back. Both use the app's normal restored-tab data prefetch, which completes before measurement; neither includes app startup, the source session's Markdown engine initialization, or a cold backend fetch. The suite asserts no message fetch during either measured switch. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
|
||||
```
|
||||
|
||||
**The tab-switch fixture is not an end-to-end cold-data benchmark.** It prefetches destination messages and returns full history. Measure cold API navigation, Home-row opening, and prefetched-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
|
||||
|
||||
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
|
||||
|
||||
For a repeatable tab-switch summary, run from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run bench:tabs
|
||||
```
|
||||
|
||||
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
|
||||
|
||||
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from prefetched tab remounts.
|
||||
|
||||
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
|
||||
|
||||
Complete original `BENCHMARK` JSON records, including samples, context, and failed records, are saved as `tab-switch-benchmark.jsonl` in Playwright's configured output directory (default: `e2e/test-results/performance`). Standard Playwright flags can override defaults when appended:
|
||||
|
||||
```sh
|
||||
bun run bench:tabs --repeat-each=3 --output=e2e/test-results/tabs-smoke
|
||||
```
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_MEMORY=1` for an opt-in renderer-main-isolate heap and DOM sample after mounted content is ready and an explicit GC completes. Probe DOM references are released before collection. This is not total desktop memory; do not mix these diagnostic runs with unprofiled latency samples. Set `OPENCODE_PERFORMANCE_TRACE_DIR` for a separate Chrome trace of each tab interaction, starting after preparation, with `session-switch:start`, `session-switch:ready`, and `session-switch:stable` markers.
|
||||
|
||||
### Cache-Enabled HTTP Fixture
|
||||
|
||||
The default tab harness uses Playwright routing for API responses. Playwright routing disables the browser HTTP cache, including for unrelated SVG assets. To measure with HTTP caching enabled, the same API handlers and tab data can run on a real loopback HTTP endpoint:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
bun e2e/performance/tab-switch-server.ts --port 4639 --dist dist
|
||||
```
|
||||
|
||||
With that fixture running, run the benchmark in a separate terminal from `packages/app`:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:4639"
|
||||
$env:OPENCODE_PERFORMANCE_HTTP_FIXTURE = "1"
|
||||
bun run bench:tabs
|
||||
```
|
||||
|
||||
Use `--dist` to select a frozen production bundle when comparing revisions. An explicit `PLAYWRIGHT_BASE_URL` means the benchmark does not rebuild or start another preview. The fixture gives hashed assets immutable cache headers; it serves the deterministic read workload, not the live OpenCode service. Each test still gets a fresh browser context, and source-session setup still occurs before the measured switch. API responses use `no-store`, service workers remain blocked, and no destination Markdown is rendered before a cold switch. Records identify the transport as `http` or `playwright-route`; keep these series separate. Unset `OPENCODE_PERFORMANCE_HTTP_FIXTURE` when returning to the default routed harness.
|
||||
|
||||
## Retained renderer memory
|
||||
|
||||
Run the catalog workload against the production app bundle:
|
||||
@@ -111,7 +158,7 @@ bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
|
||||
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
||||
|
||||
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
||||
Trace capture follows [Puppeteer's tracing lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. V8 CPU sample stacks support attribution through the frozen build's source maps. Set `OPENCODE_PERFORMANCE_STACK_TRACE=1` only when per-event timeline stacks are needed; they add substantial overhead. Keep profiled runs separate from latency distributions, including when comparing the stack-capture modes.
|
||||
|
||||
```sh
|
||||
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
||||
|
||||
@@ -5,16 +5,20 @@ type BenchmarkFixtures = {
|
||||
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
||||
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
||||
benchmarkResult: void
|
||||
traceScope: "page" | "interaction"
|
||||
}
|
||||
|
||||
export type PerformancePageDiagnostics = {
|
||||
navigations: string[]
|
||||
traceScope: "page" | "interaction"
|
||||
startTrace: () => Promise<void>
|
||||
stop: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
||||
|
||||
export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
traceScope: ["page", { option: true }],
|
||||
reportState: async ({}, use) => use({}),
|
||||
report: async ({ reportState }, use) => {
|
||||
await use((metrics, context = {}) => {
|
||||
@@ -49,9 +53,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
page: async ({ page }, use, testInfo) => {
|
||||
page: async ({ page, traceScope }, use, testInfo) => {
|
||||
const name = benchmarkName(testInfo)
|
||||
const diagnostics = await observePerformancePage(page, name)
|
||||
const diagnostics = await observePerformancePage(page, name, traceScope)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
@@ -75,25 +79,30 @@ function benchmarkName(testInfo: TestInfo) {
|
||||
|
||||
export { expect }
|
||||
|
||||
async function observePerformancePage(page: Page, name: string) {
|
||||
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
|
||||
const navigations: string[] = []
|
||||
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
||||
if (frame === page.mainFrame()) navigations.push(frame.url())
|
||||
}
|
||||
page.on("framenavigated", onNavigation)
|
||||
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
|
||||
let stopping: Promise<string | undefined> | undefined
|
||||
const diagnostics: PerformancePageDiagnostics = {
|
||||
navigations,
|
||||
traceScope,
|
||||
async startTrace() {
|
||||
stopTrace ??= await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
},
|
||||
stop() {
|
||||
page.off("framenavigated", onNavigation)
|
||||
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
||||
},
|
||||
}
|
||||
pages.set(page, diagnostics)
|
||||
if (traceScope === "page") await diagnostics.startTrace()
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
@@ -130,6 +139,7 @@ async function reportPerformancePage(name: string, diagnostics: PerformancePageD
|
||||
context: {
|
||||
platform: process.platform,
|
||||
trace,
|
||||
traceScope: diagnostics.traceScope,
|
||||
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
||||
},
|
||||
navigations: diagnostics.navigations,
|
||||
|
||||
@@ -14,7 +14,6 @@ const categories = [
|
||||
"blink.console",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
]
|
||||
|
||||
@@ -34,6 +33,9 @@ export async function startChromeTrace(page: Page, name: string): Promise<undefi
|
||||
.map((category) => category.slice(1)),
|
||||
includedCategories: [
|
||||
...categories.filter((category) => !category.startsWith("-")),
|
||||
...(process.env.OPENCODE_PERFORMANCE_STACK_TRACE === "1"
|
||||
? ["disabled-by-default-devtools.timeline.stack"]
|
||||
: []),
|
||||
...(selectors
|
||||
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
type BenchmarkRecord = {
|
||||
status?: string
|
||||
metrics?: { firstCorrectObservedMs?: unknown; stableObservedMs?: unknown } | null
|
||||
}
|
||||
|
||||
export default class TabSwitchReporter implements Reporter {
|
||||
private output = ""
|
||||
private tests: TestCase[] = []
|
||||
private results: { test: TestCase; status: TestResult["status"]; records: string[] }[] = []
|
||||
|
||||
onBegin(config: FullConfig, suite: Suite) {
|
||||
this.output = config.projects[0].outputDir
|
||||
this.tests = suite.allTests()
|
||||
}
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
this.results.push({
|
||||
test,
|
||||
status: result.status,
|
||||
records: Buffer.concat(result.stdout.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk)))
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("BENCHMARK "))
|
||||
.map((line) => line.slice("BENCHMARK ".length)),
|
||||
})
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult) {
|
||||
const file = path.join(this.output, "tab-switch-benchmark.jsonl")
|
||||
try {
|
||||
await mkdir(this.output, { recursive: true })
|
||||
await writeFile(file, this.results.flatMap((entry) => entry.records.map((raw) => `${raw}\n`)).join(""), "utf8")
|
||||
} catch (error) {
|
||||
console.error("Could not save tab-switch benchmark records:", error)
|
||||
return { status: "failed" as const }
|
||||
}
|
||||
|
||||
console.log(`\nTab-switch benchmark: ${result.status}`)
|
||||
Array.from(new Set(this.tests.map((test) => test.title))).forEach((name) => {
|
||||
const results = this.results.filter((entry) => entry.test.title === name)
|
||||
const unrun = this.tests.filter(
|
||||
(test) => test.title === name && !results.some((entry) => entry.test.id === test.id),
|
||||
).length
|
||||
const records = results.flatMap((entry) =>
|
||||
entry.records.map((raw) => {
|
||||
try {
|
||||
return { status: entry.status, record: JSON.parse(raw) as BenchmarkRecord | null }
|
||||
} catch {
|
||||
return { status: entry.status, record: { status: "invalid JSON", metrics: null } }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const passed = records.filter((entry) => entry.status === "passed" && entry.record?.status === "passed")
|
||||
const valid = passed
|
||||
.map((entry) => ({
|
||||
firstCorrectObservedMs: entry.record?.metrics?.firstCorrectObservedMs,
|
||||
stableObservedMs: entry.record?.metrics?.stableObservedMs,
|
||||
}))
|
||||
.filter(
|
||||
(metrics): metrics is { firstCorrectObservedMs: number; stableObservedMs: number } =>
|
||||
typeof metrics.firstCorrectObservedMs === "number" &&
|
||||
Number.isFinite(metrics.firstCorrectObservedMs) &&
|
||||
typeof metrics.stableObservedMs === "number" &&
|
||||
Number.isFinite(metrics.stableObservedMs),
|
||||
)
|
||||
|
||||
console.log(`\n${name}`)
|
||||
console.log(` Tests: ${counts(results.map((entry) => entry.status))}; unrun=${unrun}`)
|
||||
console.log(
|
||||
` Records: ${counts(records.map((entry) => entry.record?.status ?? "missing status"))}; ` +
|
||||
`missing=${results.filter((entry) => entry.records.length === 0).length + unrun}; ` +
|
||||
`excluded=${records.length - valid.length}; invalid metrics=${passed.length - valid.length}`,
|
||||
)
|
||||
;(["firstCorrectObservedMs", "stableObservedMs"] as const).forEach((metric) => {
|
||||
const values = valid.map((entry) => entry[metric]).sort((a, b) => a - b)
|
||||
if (values.length === 0) {
|
||||
console.log(` ${metric}: n=0, median=n/a, p95=n/a`)
|
||||
return
|
||||
}
|
||||
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2
|
||||
const p95 = values[Math.ceil(values.length * 0.95) - 1]
|
||||
console.log(` ${metric}: n=${values.length}, median=${median.toFixed(2)} ms, p95=${p95.toFixed(2)} ms`)
|
||||
})
|
||||
})
|
||||
console.log(`\nRaw BENCHMARK records: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
function counts(statuses: string[]) {
|
||||
return (
|
||||
Array.from(new Set(statuses))
|
||||
.map((status) => `${status}=${statuses.filter((value) => value === status).length}`)
|
||||
.join(", ") || "none"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { fixture } from "./timeline/session-timeline-stress.fixture"
|
||||
import { messages } from "./timeline/session-tab-switch.fixture"
|
||||
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
|
||||
|
||||
const args = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: { port: { type: "string", default: "4639" }, dist: { type: "string", default: "dist" } },
|
||||
})
|
||||
const directory = path.resolve(args.values.dist)
|
||||
const api = createMockServerHandler({
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
|
||||
vcsDiff: createReviewDiffs(),
|
||||
})
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(args.values.port),
|
||||
idleTimeout: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"id":"evt_fixture_connected","type":"server.connected","data":{}}\n\n'),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
|
||||
)
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const response = await api.handler(request)
|
||||
response.headers.set("cache-control", "no-store")
|
||||
return response
|
||||
}
|
||||
const file = Bun.file(path.join(directory, url.pathname))
|
||||
if (!url.pathname.endsWith("/") && (await file.exists())) {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"cache-control": url.pathname.startsWith("/_assets/") ? "public, max-age=31536000, immutable" : "no-cache",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(Bun.file(path.join(directory, "index.html")), { headers: { "cache-control": "no-cache" } })
|
||||
},
|
||||
})
|
||||
console.log(`Tab fixture: ${server.url} (${directory})`)
|
||||
const close = async () => {
|
||||
await server.stop(true)
|
||||
await api.dispose()
|
||||
}
|
||||
process.once("SIGINT", close)
|
||||
process.once("SIGTERM", close)
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NavigationMilestoneSample } from "./navigation-milestones"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
benchmark(
|
||||
"navigation milestones start at mousedown and wait for the expected ready controls",
|
||||
async ({ page, report }) => {
|
||||
await page.setContent('<button id="open">Open</button><input id="editor" disabled><span id="model">Loading</span>')
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: "#open",
|
||||
milestones: { editor: { selector: "#editor:enabled:focus" }, model: { selector: "#model", text: "Ready model" } },
|
||||
navigate: async () => {
|
||||
await page.getByRole("button", { name: "Open", exact: true }).dispatchEvent("mousedown", { button: 0 })
|
||||
await page.locator("#editor").evaluate((element: HTMLInputElement) => {
|
||||
element.disabled = false
|
||||
element.focus()
|
||||
})
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: { samples: NavigationMilestoneSample[] } })
|
||||
.__navigationMilestones?.samples
|
||||
return samples?.some((sample) => sample.milestones.editor && !sample.milestones.model)
|
||||
})
|
||||
await page.locator("#model").evaluate((element) => {
|
||||
element.textContent = "Ready model"
|
||||
})
|
||||
},
|
||||
})
|
||||
expect(result.summary.all.firstObservedMs).not.toBeNull()
|
||||
expect(result.summary.all.firstObservedMs).toBeGreaterThan(result.summary.milestones.editor.firstObservedMs!)
|
||||
expect(await page.evaluate(() => "__navigationMilestones" in window)).toBe(false)
|
||||
report(result)
|
||||
},
|
||||
)
|
||||
@@ -36,7 +36,7 @@ export async function measureNavigationMilestones(
|
||||
page: Page,
|
||||
input: {
|
||||
triggerSelector: string
|
||||
milestones: Record<string, { selector: string; visible?: boolean }>
|
||||
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
@@ -47,11 +47,19 @@ export async function measureNavigationMilestones(
|
||||
const marked = new Set<string>()
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string) =>
|
||||
const visible = (selector: string, text?: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
||||
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
|
||||
if (text !== undefined && element.textContent?.replace(/\s+/g, " ").trim() !== text) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.top < innerHeight &&
|
||||
rect.right > 0 &&
|
||||
rect.left < innerWidth
|
||||
)
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
@@ -61,7 +69,9 @@ export async function measureNavigationMilestones(
|
||||
const current = Object.fromEntries(
|
||||
Object.entries(milestones).map(([name, milestone]) => [
|
||||
name,
|
||||
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
|
||||
milestone.visible === false
|
||||
? !document.querySelector(milestone.selector)
|
||||
: visible(milestone.selector, milestone.text),
|
||||
]),
|
||||
)
|
||||
samples.push({
|
||||
@@ -93,36 +103,46 @@ export async function measureNavigationMilestones(
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
||||
started = performance.now()
|
||||
performance.mark("opencode.navigation.click")
|
||||
sample()
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
const start = (event: MouseEvent) => {
|
||||
if (started !== undefined || event.button !== 0) return
|
||||
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
||||
started = performance.now()
|
||||
performance.mark("opencode.navigation.start")
|
||||
sample()
|
||||
}
|
||||
document.addEventListener("mousedown", start, true)
|
||||
document.addEventListener("click", start, true)
|
||||
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("mousedown", start, true)
|
||||
document.removeEventListener("click", start, true)
|
||||
},
|
||||
}
|
||||
},
|
||||
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
|
||||
)
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
||||
?.samples
|
||||
if (!samples || samples.length < 3) return false
|
||||
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
||||
})
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeNavigationMilestones(samples), samples }
|
||||
try {
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
||||
?.samples
|
||||
return (
|
||||
samples &&
|
||||
samples.length >= 3 &&
|
||||
samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
||||
)
|
||||
})
|
||||
const samples = await page.evaluate(
|
||||
() => (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!.samples,
|
||||
)
|
||||
return { summary: summarizeNavigationMilestones(samples), samples }
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
const host = window as Window & { __navigationMilestones?: NavigationMilestoneProbe }
|
||||
host.__navigationMilestones?.stop()
|
||||
delete host.__navigationMilestones
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
trace: "off",
|
||||
video: "off",
|
||||
})
|
||||
|
||||
for (const entry of ["home", "session"] as const) {
|
||||
benchmark(`entry: new session from ${entry}`, async ({ page, report }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page, { sessionIDs: entry === "home" ? [] : [fixture.sourceID] })
|
||||
await page.goto(entry === "home" ? "/" : stressSessionHref(fixture.sourceID))
|
||||
if (entry === "session") await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
const trigger = entry === "home" ? '[data-action="home-new-session"]' : 'button[aria-label="New session"]'
|
||||
await expect(page.locator(trigger)).toBeVisible()
|
||||
await expect(page.locator('[data-component="new-session"]')).toHaveCount(0)
|
||||
const writes: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/") && !["GET", "HEAD", "OPTIONS"].includes(request.method()))
|
||||
writes.push(request.method())
|
||||
})
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: trigger,
|
||||
milestones: {
|
||||
editor: {
|
||||
selector: '[data-component="new-session"] [data-component="composer-editor"][contenteditable="true"]:focus',
|
||||
},
|
||||
model: { selector: '[data-component="new-session"] [data-action="composer-model"]', text: "Claude Opus 4.6" },
|
||||
project: { selector: '[data-component="new-session"] [data-action="prompt-project"]' },
|
||||
tab: { selector: '[data-titlebar-tab-link][href^="/new-session?draftId="]' },
|
||||
},
|
||||
navigate: () => page.locator(trigger).click(),
|
||||
})
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
const editor = page.locator('[data-component="new-session"] [data-component="composer-editor"]')
|
||||
await expect(editor).toHaveText("")
|
||||
await page.keyboard.type("Draft input")
|
||||
await expect(editor).toHaveText("Draft input")
|
||||
expect(writes).toEqual([])
|
||||
report(
|
||||
{
|
||||
firstCorrectObservedMs: result.summary.all.firstObservedMs,
|
||||
stableObservedMs: result.summary.all.stableObservedMs,
|
||||
...result,
|
||||
},
|
||||
{ entry, data: "fixture", inputEvent: "mousedown" },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
benchmark("entry: cold session from Home", async ({ page, report }) => {
|
||||
const requests: string[] = []
|
||||
await mockStressTimeline(page, {
|
||||
onMessages: (request) => {
|
||||
if (request.phase === "start") requests.push(request.sessionID)
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: [] })
|
||||
await page.goto("/")
|
||||
const selector = `[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"] [data-component="home-session-row"]`
|
||||
await expect(page.locator(selector)).toBeVisible()
|
||||
expect(requests).not.toContain(fixture.targetID)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
sourceIDs: [],
|
||||
lastID: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
requiredPartID: fixture.expected.targetPartIDs.at(-1)!,
|
||||
href,
|
||||
triggerSelector: selector,
|
||||
switch: async () => {
|
||||
await page.locator(selector).click()
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
},
|
||||
})
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
await expect(
|
||||
page.locator(`[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-link][href="${href}"]`),
|
||||
).toHaveCount(1)
|
||||
expect(requests).toContain(fixture.targetID)
|
||||
report(result, { entry: "home", data: "cold paginated fixture", inputEvent: "mousedown" })
|
||||
})
|
||||
@@ -1,71 +1,129 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { expected, messages, workload } from "./session-tab-switch.fixture"
|
||||
import {
|
||||
createReviewDiffs,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} 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 },
|
||||
]
|
||||
{ cache: "cold", review: "closed" },
|
||||
{ cache: "cold", review: "open" },
|
||||
{ cache: "warm", review: "closed" },
|
||||
{ cache: "warm", review: "open" },
|
||||
{ cache: "warm", review: "resized" },
|
||||
] as const
|
||||
|
||||
const viewport = { width: 1440, height: 900 }
|
||||
const reviewDiffs = createReviewDiffs()
|
||||
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
|
||||
|
||||
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)
|
||||
}
|
||||
if (scenario.resized) await openReviewPane(page)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
benchmark(`tab switch: ${scenario.cache}, review ${scenario.review}`, async ({ page, report }, testInfo) => {
|
||||
const requests = await prepareSessionTabs(page)
|
||||
if (scenario.review === "open") await openReviewPane(page)
|
||||
if (scenario.cache === "warm") {
|
||||
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await expectReadyTimeline(page, fixture.targetID)
|
||||
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
}
|
||||
if (scenario.review === "resized") await openReviewPane(page)
|
||||
await expectReadyTimeline(page, fixture.sourceID)
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const requestsBefore = requests.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),
|
||||
})
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: messages[fixture.targetID].map((message) => message.id),
|
||||
sourceIDs: messages[fixture.sourceID].map((message) => message.id),
|
||||
lastID: expected[fixture.targetID].lastID,
|
||||
requiredPartID: expected[fixture.targetID].answerID,
|
||||
href: stressSessionHref(fixture.targetID),
|
||||
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
||||
})
|
||||
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
expect(requests).toHaveLength(requestsBefore)
|
||||
await expectReadyTimeline(page, fixture.targetID)
|
||||
report(
|
||||
{
|
||||
...result,
|
||||
messageRequestsDuringSwitch: requests.length - requestsBefore,
|
||||
rendererMemory:
|
||||
process.env.OPENCODE_PERFORMANCE_MEMORY === "1" ? await retainedRendererMemory(page) : undefined,
|
||||
},
|
||||
{
|
||||
...scenario,
|
||||
...workload,
|
||||
viewport,
|
||||
browserVersion: page.context().browser()!.version(),
|
||||
serviceWorkers: "blocked",
|
||||
reviewFiles: scenario.review === "closed" ? 0 : reviewDiffs.length,
|
||||
data: "prefetched",
|
||||
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
requireReadyAnswer: true,
|
||||
},
|
||||
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 })
|
||||
if (testInfo.repeatEachIndex === 0) {
|
||||
await page.screenshot({ path: testInfo.outputPath("destination.png") })
|
||||
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function prepareSessionTabs(page: Page) {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "GET") return
|
||||
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (match) requests.push(decodeURIComponent(match[1]))
|
||||
})
|
||||
if (process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE !== "1")
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
// Return the full history so every scenario exercises a long loaded timeline.
|
||||
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
|
||||
vcsDiff: reviewDiffs,
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
// Restored tabs prefetch their data even when their transcript has never rendered.
|
||||
const prefetch = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
expect(await (await prefetch).finished()).toBeNull()
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expectReadyTimeline(page, fixture.sourceID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
|
||||
expect(requests.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
|
||||
return requests
|
||||
}
|
||||
|
||||
async function expectReadyTimeline(page: Page, sessionID: string) {
|
||||
const answer = page.locator(`[data-timeline-part-id="${expected[sessionID].answerID}"]`)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(answer.getByRole("table")).toHaveCount(1)
|
||||
await expect(answer.locator("pre")).toHaveCount(4)
|
||||
await expect
|
||||
.poll(() => answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })))
|
||||
.toBe(true)
|
||||
await waitForStableTimeline(page, expected[sessionID].lastID)
|
||||
await expect(page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -80,4 +138,20 @@ async function openReviewPane(page: Page) {
|
||||
const text = document.querySelector("#review-panel")?.textContent ?? ""
|
||||
return text.includes("generated-000.ts") && text.includes("+3")
|
||||
})
|
||||
await page.locator('[data-slot="session-chat-panel"]').evaluate(async (panel) => {
|
||||
await Promise.all(panel.getAnimations().map((animation) => animation.finished))
|
||||
})
|
||||
}
|
||||
|
||||
async function retainedRendererMemory(page: Page) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await cdp.send("HeapProfiler.collectGarbage")
|
||||
return {
|
||||
heap: await cdp.send("Runtime.getHeapUsage"),
|
||||
dom: await cdp.send("Memory.getDOMCounters"),
|
||||
}
|
||||
} finally {
|
||||
await cdp.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,66 +2,77 @@ 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>
|
||||
for (const tag of ["a", "button"] as const) {
|
||||
benchmark(
|
||||
`starts at ${tag} mousedown and excludes hidden or unfinished destination content`,
|
||||
async ({ page, report }) => {
|
||||
await page.setContent(`
|
||||
<${tag} id="destination" ${tag === "a" ? 'href="/session/destination"' : 'type="button"'}>Destination</${tag}>
|
||||
<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.evaluate(() => {
|
||||
document.querySelector("#destination")!.addEventListener("mousedown", () => {
|
||||
const row = document.querySelector<HTMLElement>("[data-message-id]")!
|
||||
row.dataset.messageId = "destination"
|
||||
row.style.visibility = "hidden"
|
||||
})
|
||||
})
|
||||
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"
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: ["destination"],
|
||||
sourceIDs: ["source"],
|
||||
lastID: "destination",
|
||||
requiredPartID: "answer",
|
||||
requireBottomAnchor: false,
|
||||
href: "/session/destination",
|
||||
triggerSelector: tag === "button" ? "#destination" : undefined,
|
||||
switch: async () => {
|
||||
// No click is dispatched: the probe must observe the event that activates tabs.
|
||||
await page
|
||||
.getByRole(tag === "a" ? "link" : "button", { name: "Destination", exact: true })
|
||||
.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!)
|
||||
expect(await page.evaluate(() => "__sessionSwitchProbe" in window)).toBe(false)
|
||||
report(result)
|
||||
},
|
||||
})
|
||||
expect(result.blankSamples).toBeGreaterThan(0)
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
|
||||
report(result)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,124 +14,132 @@ async function installSessionSwitchProbe(
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
triggerSelector?: string
|
||||
href: string
|
||||
},
|
||||
) {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const reviewLevels: Record<string, string> = {
|
||||
panel: "#review-panel",
|
||||
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"]',
|
||||
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
||||
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
||||
}
|
||||
const initialReviewNodes: Record<string, Element | null> = {}
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
setTimeout(() => {
|
||||
await page.evaluate(
|
||||
({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, triggerSelector, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const reviewLevels: Record<string, string> = {
|
||||
panel: "#review-panel",
|
||||
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"]',
|
||||
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
||||
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
||||
}
|
||||
const initialReviewNodes: Record<string, Element | null> = {}
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
||||
const initialReviewFile = initialReviewNodes.file
|
||||
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
||||
const initial = initialReviewNodes[name]
|
||||
if (!initial) return []
|
||||
const current = document.querySelector(selector)
|
||||
return current && current !== initial ? [name] : []
|
||||
})
|
||||
const review = reviewPanel
|
||||
? {
|
||||
fileHost: !!reviewFile,
|
||||
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
||||
header:
|
||||
reviewPanel
|
||||
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
||||
?.textContent?.trim() ?? "",
|
||||
replacedLevels,
|
||||
}
|
||||
: undefined
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
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)
|
||||
.map((element) => element.dataset.messageId!)
|
||||
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
|
||||
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)
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
||||
const initialReviewFile = initialReviewNodes.file
|
||||
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
||||
const initial = initialReviewNodes[name]
|
||||
if (!initial) return []
|
||||
const current = document.querySelector(selector)
|
||||
return current && current !== initial ? [name] : []
|
||||
})
|
||||
const review = reviewPanel
|
||||
? {
|
||||
fileHost: !!reviewFile,
|
||||
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
||||
header:
|
||||
reviewPanel
|
||||
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
||||
?.textContent?.trim() ?? "",
|
||||
replacedLevels,
|
||||
}
|
||||
: undefined
|
||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: visible.filter((id) => destination.has(id)),
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
requiredPartVisible,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: [],
|
||||
source: [],
|
||||
hasVisibleRows: false,
|
||||
last: false,
|
||||
requiredPartVisible: requiredPartID ? false : undefined,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
review,
|
||||
})
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
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)
|
||||
.map((element) => element.dataset.messageId!)
|
||||
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
|
||||
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)
|
||||
})
|
||||
: undefined
|
||||
const spacer = root
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: visible.filter((id) => destination.has(id)),
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
requiredPartVisible,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: [],
|
||||
source: [],
|
||||
hasVisibleRows: false,
|
||||
last: false,
|
||||
requiredPartVisible: requiredPartID ? false : undefined,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
review,
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
const start = (event: MouseEvent) => {
|
||||
if (started !== undefined || event.button !== 0) return
|
||||
const trigger = event.target instanceof Element ? event.target.closest(triggerSelector ?? "a") : undefined
|
||||
if (!trigger || (!triggerSelector && trigger.getAttribute("href") !== href)) return
|
||||
started = performance.now()
|
||||
performance.mark("session-switch:start", { startTime: started })
|
||||
for (const [name, selector] of Object.entries(reviewLevels)) {
|
||||
initialReviewNodes[name] = document.querySelector(selector)
|
||||
}
|
||||
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)
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("mousedown", start, true)
|
||||
document.removeEventListener("click", start, true)
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
|
||||
document.addEventListener("mousedown", start, true)
|
||||
document.addEventListener("click", start, true)
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("mousedown", start, true)
|
||||
document.removeEventListener("click", start, true)
|
||||
Object.keys(initialReviewNodes).forEach((key) => (initialReviewNodes[key] = null))
|
||||
},
|
||||
}
|
||||
},
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForStableSessionSwitch(page: Page) {
|
||||
@@ -159,9 +167,17 @@ async function collectSessionSwitchResult(page: Page) {
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
|
||||
probe.stop()
|
||||
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
|
||||
return probe.samples
|
||||
})
|
||||
return classifySessionSwitch(samples)
|
||||
const result = classifySessionSwitch(samples)
|
||||
await page.evaluate(({ firstCorrectObservedMs, stableObservedMs }) => {
|
||||
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
|
||||
if (firstCorrectObservedMs !== null)
|
||||
performance.mark("session-switch:ready", { startTime: start + firstCorrectObservedMs })
|
||||
if (stableObservedMs !== null) performance.mark("session-switch:stable", { startTime: start + stableObservedMs })
|
||||
}, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function measureSessionSwitch(
|
||||
@@ -172,6 +188,7 @@ export async function measureSessionSwitch(
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
triggerSelector?: string
|
||||
href: string
|
||||
switch: () => Promise<void>
|
||||
},
|
||||
@@ -185,6 +202,7 @@ export async function measureSessionSwitch(
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
export const exchanges = 200
|
||||
|
||||
export const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((sessionID) => [
|
||||
sessionID,
|
||||
Array.from({ length: exchanges }, (_, index) => {
|
||||
const seed = fixture.messages[fixture.targetID]
|
||||
const user = seed[(index % (seed.length / 2)) * 2]!
|
||||
const assistant = seed[(index % (seed.length / 2)) * 2 + 1]!
|
||||
if (user.type !== "user" || assistant.type !== "assistant") throw new Error("Expected a user/assistant pair")
|
||||
const suffix = `${sessionID}_${String(index).padStart(4, "0")}`
|
||||
return [
|
||||
{
|
||||
...user,
|
||||
id: `msg_user_${suffix}`,
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
},
|
||||
{
|
||||
...assistant,
|
||||
id: `msg_assistant_${suffix}`,
|
||||
time: { created: 1700000001000 + index * 10_000, completed: 1700000008000 + index * 10_000 },
|
||||
content: [
|
||||
...assistant.content
|
||||
.filter((part) => part.type !== "text")
|
||||
.map((part) => (part.type === "tool" ? { ...part, id: `${part.id}_${suffix}` } : part)),
|
||||
{ type: "text", text: complexMarkdown(sessionID, index) },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
}).flat(),
|
||||
]),
|
||||
)
|
||||
|
||||
export const expected = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((sessionID) => [
|
||||
sessionID,
|
||||
{
|
||||
lastID: messages[sessionID].at(-2)!.id,
|
||||
answerID: `${messages[sessionID].at(-1)!.id}:text:0`,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
export const workload = {
|
||||
fixture: "long-complex-markdown-v1",
|
||||
exchangesPerSession: exchanges,
|
||||
messagesPerSession: exchanges * 2,
|
||||
history: "full fixture history in one response",
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(messages).map(([sessionID, items]) => [
|
||||
sessionID,
|
||||
{
|
||||
payloadBytes: Buffer.byteLength(JSON.stringify(items)),
|
||||
markdownBytes: items.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(message.type === "assistant"
|
||||
? message.content.reduce(
|
||||
(size, part) => size + (part.type === "text" ? Buffer.byteLength(part.text) : 0),
|
||||
0,
|
||||
)
|
||||
: 0),
|
||||
0,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
function complexMarkdown(sessionID: string, index: number) {
|
||||
return `## Renderer review ${sessionID} / ${index}
|
||||
|
||||
Preserve **semantic identity**, *measured geometry*, and ~~obsolete estimates~~ when switching sessions. The \`measureElement(node)\` result must agree with the [rendering contract](https://example.com/rendering/${sessionID}/${index}).
|
||||
|
||||
> A completed answer contains formatted prose, highlighted source, and structured results.
|
||||
> Keep the previous view until the destination is ready, rather than exposing partially formatted content.
|
||||
|
||||
### Readiness checklist
|
||||
|
||||
- [x] Resolve the destination session and its messages.
|
||||
- [x] Parse Markdown and highlight fenced code.
|
||||
- [ ] Verify a different panel width.
|
||||
- Preserve the bottom anchor.
|
||||
- Reuse the measured rows when their width matches.
|
||||
|
||||
| Stage | Input | Expected result | Verification |
|
||||
| :--- | ---: | :--- | :--- |
|
||||
${Array.from({ length: 8 }, (_, row) => `| stage-${index}-${row} | ${index * 8 + row} | **ready** with \`row[${row}]\` | stable geometry and visible content |`).join("\n")}
|
||||
|
||||
### Implementation
|
||||
|
||||
\`\`\`tsx
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
|
||||
type Row = { id: string; title: string; ready: boolean; height: number }
|
||||
|
||||
export function SessionRows${index}(props: { rows: Row[]; selected: string }) {
|
||||
const visible = createMemo(() => props.rows.filter((row) => row.ready))
|
||||
return (
|
||||
<section aria-label="${sessionID}-${index}">
|
||||
<For each={visible()}>{(row) => (
|
||||
<article data-selected={row.id === props.selected}>
|
||||
<h3>{row.title}</h3>
|
||||
<Show when={row.height > 0} fallback={<span>Measuring</span>}>
|
||||
<output>{row.height.toFixed(2)} pixels</output>
|
||||
</Show>
|
||||
</article>
|
||||
)}</For>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify({ session: sessionID, exchange: index, stages: ["hydrate", "parse", "highlight", "measure"], viewport: { width: 1440, height: 900 }, cache: { markdown: true, geometry: true } }, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`sql
|
||||
SELECT session_id, COUNT(*) AS messages, MAX(created_at) AS latest
|
||||
FROM session_message
|
||||
WHERE session_id = '${sessionID}' AND ordinal >= ${index}
|
||||
GROUP BY session_id
|
||||
ORDER BY latest DESC;
|
||||
\`\`\`
|
||||
|
||||
### Verification
|
||||
|
||||
1. Open the long source session and wait for its final answer.
|
||||
2. Select the destination tab, without changing the viewport.
|
||||
3. Confirm that **all Markdown is ready** and the bottom anchor is correct.
|
||||
|
||||
\`\`\`bash
|
||||
bun typecheck
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts
|
||||
git diff --check # ${sessionID}-${index}
|
||||
\`\`\`
|
||||
|
||||
**Review complete: ${sessionID} / ${index}.**
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import type { FullConfig, Suite, TestCase, TestResult } from "@playwright/test/reporter"
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import TabSwitchReporter from "../tab-switch-reporter"
|
||||
|
||||
test("summarizes each scenario and saves complete records in the configured output directory", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
|
||||
const output = path.join(root, "configured-output")
|
||||
const log = spyOn(console, "log").mockImplementation(() => {})
|
||||
try {
|
||||
const reporter = new TabSwitchReporter()
|
||||
const cases = Array.from(
|
||||
{ length: 23 },
|
||||
(_, index) => ({ id: String(index), title: index < 20 ? "cold" : "warm" }) as TestCase,
|
||||
)
|
||||
const records = cases.map((_, index) => {
|
||||
const first = index < 20 ? 20 - index : [40, 0, 20][index - 20]
|
||||
return JSON.stringify({
|
||||
status: "passed",
|
||||
metrics: {
|
||||
firstCorrectObservedMs: first,
|
||||
stableObservedMs: first * 2,
|
||||
samples: [{ observedAtMs: first, destination: ["answer"], source: [] }],
|
||||
},
|
||||
extra: { preserved: "\u03b1" },
|
||||
})
|
||||
})
|
||||
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
|
||||
cases.forEach((item, index) => {
|
||||
const bytes = Buffer.from(`BENCHMARK ${records[index]}\r\n`)
|
||||
const split = bytes.indexOf(Buffer.from("\u03b1")) + 1
|
||||
reporter.onTestEnd(item, {
|
||||
status: "passed",
|
||||
stdout:
|
||||
index === 0
|
||||
? [bytes.subarray(0, split), bytes.subarray(split)]
|
||||
: ["other output\nBENCHMARK_PAGE {}\nBENCH", "MARK ", records[index], "\n"],
|
||||
} as TestResult)
|
||||
})
|
||||
await reporter.onEnd({ status: "passed", startTime: new Date(0), duration: 0 })
|
||||
|
||||
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(`${records.join("\n")}\n`)
|
||||
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
|
||||
expect(summary).toContain("cold\n Tests: passed=20; unrun=0")
|
||||
expect(summary).toContain("warm\n Tests: passed=3; unrun=0")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=20, median=10.50 ms, p95=19.00 ms")
|
||||
expect(summary).toContain("stableObservedMs: n=20, median=21.00 ms, p95=38.00 ms")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=3, median=20.00 ms, p95=40.00 ms")
|
||||
expect(summary).toContain("stableObservedMs: n=3, median=40.00 ms, p95=80.00 ms")
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports failures, missing records, and invalid metrics without discarding raw data", async () => {
|
||||
const output = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
|
||||
const log = spyOn(console, "log").mockImplementation(() => {})
|
||||
try {
|
||||
const reporter = new TabSwitchReporter()
|
||||
const entries = [
|
||||
{
|
||||
status: "passed",
|
||||
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":12,"stableObservedMs":24}}',
|
||||
},
|
||||
{
|
||||
status: "failed",
|
||||
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
|
||||
},
|
||||
{
|
||||
status: "passed",
|
||||
raw: '{"status":"failed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
|
||||
},
|
||||
{ status: "timedOut", raw: '{"status":"failed","metrics":null,"error":"Benchmark did not report metrics"}' },
|
||||
{
|
||||
status: "passed",
|
||||
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":null,"stableObservedMs":40}}',
|
||||
},
|
||||
{ status: "failed", raw: '{"status":' },
|
||||
{ status: "skipped", raw: undefined },
|
||||
] as const
|
||||
const cases = Array.from(
|
||||
{ length: entries.length + 2 },
|
||||
(_, index) => ({ id: String(index), title: index <= entries.length ? "cold" : "empty" }) as TestCase,
|
||||
)
|
||||
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
|
||||
entries.forEach((entry, index) => {
|
||||
reporter.onTestEnd(cases[index], {
|
||||
status: entry.status,
|
||||
stdout: entry.raw === undefined ? [] : [`BENCHMARK ${entry.raw}\n`],
|
||||
} as TestResult)
|
||||
})
|
||||
await reporter.onEnd({ status: "interrupted", startTime: new Date(0), duration: 0 })
|
||||
|
||||
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(
|
||||
entries.flatMap((entry) => (entry.raw === undefined ? [] : [`${entry.raw}\n`])).join(""),
|
||||
)
|
||||
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
|
||||
expect(summary).toContain("Tab-switch benchmark: interrupted")
|
||||
expect(summary).toContain("Tests: passed=3, failed=2, timedOut=1, skipped=1; unrun=1")
|
||||
expect(summary).toContain("Records: passed=3, failed=2, invalid JSON=1; missing=2; excluded=5; invalid metrics=1")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=1, median=12.00 ms, p95=12.00 ms")
|
||||
expect(summary).toContain("stableObservedMs: n=1, median=24.00 ms, p95=24.00 ms")
|
||||
expect(summary).toContain("empty\n Tests: none; unrun=1")
|
||||
expect(summary).toContain("Records: none; missing=1; excluded=0; invalid metrics=0")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=0, median=n/a, p95=n/a")
|
||||
expect(summary).toContain("stableObservedMs: n=0, median=n/a, p95=n/a")
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
await rm(output, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
for (const window of ["assistant-only", "mixed"] as const) {
|
||||
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
|
||||
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
|
||||
// Both 20-message pages begin with an assistant; only page three supplies its parent.
|
||||
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
|
||||
const id = `msg_hydration_${index}`
|
||||
const time = { created: 1700000000000 + index * 1_000 }
|
||||
if (index === 0 || (window === "mixed" && index === 39))
|
||||
return { id, type: "user", time, text: `Prompt ${index}` }
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
time: { ...time, completed: time.created + 500 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
}
|
||||
})
|
||||
const gates = [21, 1].map((index) => ({
|
||||
before: messages[index]!.id,
|
||||
parent: messages[index === 21 ? 1 : 0]!.id,
|
||||
requested: Promise.withResolvers<void>(),
|
||||
release: Promise.withResolvers<void>(),
|
||||
}))
|
||||
const requests: (string | undefined)[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: [session],
|
||||
beforeMessagesResponse: async ({ before }) => {
|
||||
requests.push(before)
|
||||
if (!before) return
|
||||
const gate = gates.find((gate) => gate.before === before)
|
||||
if (!gate) throw new Error(`Unexpected older-page boundary: ${before}`)
|
||||
gate.requested.resolve()
|
||||
await gate.release.promise
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
expect(limit).toBe(20)
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
|
||||
const markdown = tail.locator('[data-component="markdown"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: tail })
|
||||
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
|
||||
})
|
||||
const expectReadyTail = async () => {
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.getByRole("heading", { name: "Hydrated tail", exact: true })).toBeInViewport({ ratio: 1 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(stressSessionHref(session.id))
|
||||
await gates[0]!.requested.promise
|
||||
// This must pass while the first older response is still held.
|
||||
await expectReadyTail()
|
||||
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
|
||||
if (window === "mixed")
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
|
||||
).toBeInViewport()
|
||||
const original = await markdown.elementHandle()
|
||||
|
||||
for (const gate of gates) {
|
||||
await gate.requested.promise
|
||||
gate.release.resolve()
|
||||
// Parent ownership proves the page reached the projection, not just the network.
|
||||
await expect(orphan).toHaveAttribute("data-message-id", gate.parent)
|
||||
await expectReadyTail()
|
||||
expect(await markdown.evaluate((element, original) => element === original, original)).toBe(true)
|
||||
}
|
||||
expect(requests).toEqual([undefined, ...gates.map((gate) => gate.before)])
|
||||
const ids = await content
|
||||
.locator("[data-timeline-part-id]")
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-part-id")))
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
} finally {
|
||||
gates.forEach((gate) => gate.release.resolve())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { expected, messages } from "../performance/timeline/session-tab-switch.fixture"
|
||||
import { installTimelineSettings, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
type Reveal = { pending: number; clipped: string[]; bottomError: number; tables: number; codeBlocks: number }
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`reveals measured Markdown after the worker completes at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/markdown\.worker(?:-[^/?]+\.js|\.ts)(?:\?.*)?$/, async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
await page.addInitScript((partID) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const answer = document.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const content = answer?.closest<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const root = content?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
if (!answer || !content || !root || !content.checkVisibility({ checkVisibilityCSS: true })) return
|
||||
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
|
||||
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
|
||||
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
|
||||
? [row.dataset.timelineKey!]
|
||||
: [],
|
||||
),
|
||||
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
|
||||
tables: answer.querySelectorAll("table").length,
|
||||
codeBlocks: answer.querySelectorAll("pre").length,
|
||||
}
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] })
|
||||
}, expected[fixture.sourceID].answerID)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
pageMessages: () => ({ items: messages[fixture.sourceID] }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
try {
|
||||
await page.goto(stressSessionHref(fixture.sourceID), { waitUntil: "domcontentloaded" })
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
|
||||
release.resolve()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
|
||||
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
|
||||
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("scrolls within a long answer without mounting unrelated history", async ({ page }) => {
|
||||
await openTimeline(page, messages[fixture.sourceID])
|
||||
const answer = page.locator(`[data-timeline-part-id="${expected[fixture.sourceID].answerID}"]`)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(answer.getByRole("table")).toHaveCount(1)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: answer })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const rows = page.locator("[data-timeline-key]")
|
||||
const keys = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-timeline-key")),
|
||||
)
|
||||
const top = await answer.evaluate((element) => element.getBoundingClientRect().top)
|
||||
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -240)
|
||||
|
||||
await expect.poll(() => answer.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top + 240, 0)
|
||||
expect(
|
||||
await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-key"))),
|
||||
).toEqual(keys)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
})
|
||||
|
||||
test("fills a short cold transcript before revealing it", async ({ page }) => {
|
||||
const history = messages[fixture.sourceID].slice(-6).map((message, index) => {
|
||||
if (message.type === "user") return { ...message, text: `Prompt ${index}`, metadata: undefined }
|
||||
if (message.type === "assistant")
|
||||
return { ...message, content: [{ type: "text" as const, text: `**Answer ${index}**` }] }
|
||||
return message
|
||||
})
|
||||
await openTimeline(page, history)
|
||||
for (const message of history) {
|
||||
if (message.type === "user") {
|
||||
await expect(page.locator(`[data-timeline-row="UserMessage"][data-message-id="${message.id}"]`)).toBeInViewport()
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
const answer = page.locator(`[data-timeline-part-id="${message.id}:text:0"]`)
|
||||
await expect(answer).toBeInViewport()
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function openTimeline(page: Page, history: SessionMessageInfo[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
pageMessages: () => ({ items: history }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
@@ -102,6 +102,14 @@ test("cramped tabs only show the close button for the active tab", async ({ page
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
|
||||
}
|
||||
})
|
||||
|
||||
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
|
||||
@@ -288,11 +296,12 @@ async function mockServer(page: Page) {
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/inbox`)) return json(route, { data: [] })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
|
||||
@@ -20,95 +20,134 @@ const inventory: WorktreeDirectory[] = [
|
||||
|
||||
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()
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test.describe(theme, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
}, theme)
|
||||
})
|
||||
|
||||
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" })
|
||||
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, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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 leave send neutral; 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, "contrast")
|
||||
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, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
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 view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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]) {
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory], draft = false) {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -159,18 +198,32 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
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")
|
||||
})
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: root, expanded: true }] },
|
||||
lastProject: { local: root },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID: "draft_workspace_accent", server, directory: root }]),
|
||||
)
|
||||
},
|
||||
{ root, server },
|
||||
)
|
||||
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}`)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/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")
|
||||
if (!draft) await expectSessionReady(page, { server, sessionID, title })
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expectAppVisible(composer)
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
@@ -355,6 +355,14 @@ test.describe("smoke: session timeline", () => {
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
|
||||
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||
// The shell is below a long diff; reveal it rather than depending on offscreen overscan.
|
||||
while ((await shell.count()) === 0) {
|
||||
const before = await timelineState(page)
|
||||
await timelineScroller(page).press("PageDown")
|
||||
await expect.poll(async () => (await timelineState(page)).signature).not.toBe(before.signature)
|
||||
}
|
||||
await shell.scrollIntoViewIfNeeded()
|
||||
await expect(shell).toBeInViewport()
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
@@ -694,6 +702,7 @@ async function expectSessionTimelineReady(
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
await waitForTimelineStable(page)
|
||||
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
||||
const currentState = await timelineState(page)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
// An empty draft must remain usable when the file viewer is unavailable.
|
||||
await page.route(/(?:\/_assets\/file-(?!icon-)[^/]+\.js|\/session-ui\/src\/components\/file\.tsx)(?:\?|$)/, (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -60,7 +66,7 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
|
||||
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
|
||||
],
|
||||
recent: [],
|
||||
recent: [{ providerID: "opencode-go", modelID: "go-model-1" }],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
@@ -81,11 +87,73 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
await modelControl.click()
|
||||
await page.locator('[data-option-key="opencode:free-model"]').click()
|
||||
await expect(modelControl).toContainText("Free Model")
|
||||
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
})
|
||||
|
||||
test("restores each existing session's model and variant when switching tabs", async ({ page }) => {
|
||||
const sessions = ["A", "B"].map((name) => ({
|
||||
...fixture.sessions[0],
|
||||
id: `ses_model_${name}`,
|
||||
title: `Model ${name}`,
|
||||
model: { id: `model-${name}`, providerID: "opencode", variant: "balanced" },
|
||||
}))
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: Object.fromEntries(
|
||||
sessions.map((session) => [
|
||||
session.model.id,
|
||||
{
|
||||
id: session.model.id,
|
||||
name: session.title,
|
||||
limit: { context: 200_000 },
|
||||
variants: { balanced: {}, high: {} },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: sessions[0]!.model.id },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
|
||||
const hrefA = stressSessionHref(sessions[0]!.id)
|
||||
const hrefB = stressSessionHref(sessions[1]!.id)
|
||||
await page.goto(hrefA)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const modelControl = composer.locator('[data-action="composer-model"]')
|
||||
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
|
||||
await expect(modelControl).toHaveText("Model A")
|
||||
await expect(variant).toHaveText("balanced")
|
||||
await variant.click()
|
||||
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
|
||||
await expect(variant).toHaveText("high")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
|
||||
await expect(page).toHaveURL(hrefB)
|
||||
await expect(modelControl).toHaveText("Model B")
|
||||
await expect(variant).toHaveText("balanced")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${hrefA}"]`).click()
|
||||
await expect(page).toHaveURL(hrefA)
|
||||
await expect(modelControl).toHaveText("Model A")
|
||||
await expect(variant).toHaveText("high")
|
||||
})
|
||||
|
||||
@@ -47,7 +47,6 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
await page.addInitScript(
|
||||
@@ -135,13 +134,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const transport = HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, state)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
const transport = createMockServerHandler(config)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
@@ -173,6 +166,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockServerHandler(config: MockServerConfig) {
|
||||
return HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, { cursors: new Map<string, string>(), nextCursor: 0 })),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
"bench:entry": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-entry-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -12,12 +12,7 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}) {
|
||||
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
@@ -26,7 +21,6 @@ export function Composer(props: {
|
||||
<div class="flex flex-col gap-3">
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
|
||||
@@ -37,7 +37,6 @@ export type ComposerMode = "normal" | "shell"
|
||||
|
||||
export type ComposerEditorProps = {
|
||||
controller: ComposerEditorModel
|
||||
accentSubmit?: boolean
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
borderUnderlay?: boolean
|
||||
@@ -151,7 +150,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
props.controller.setEditor(element)
|
||||
renderComposerEditor(element, props.controller.parts())
|
||||
}}
|
||||
data-component="composer-editor"
|
||||
role="textbox"
|
||||
@@ -265,7 +263,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
accent={props.accentSubmit}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
@@ -751,7 +748,6 @@ export function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
disabled: boolean
|
||||
accent?: boolean
|
||||
sendLabel: string
|
||||
stopLabel: string
|
||||
onSubmit: () => void
|
||||
@@ -770,16 +766,10 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
classList={{
|
||||
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
|
||||
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
|
||||
}}
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
props.accent && !props.stopping && !props.disabled
|
||||
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
|
||||
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -278,16 +278,15 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const directory = project?.worktree ?? session.location.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
if (!options?.background) void ctx.data.session.message.sync(session.id).catch(() => undefined)
|
||||
// Commit cache/project changes with navigation instead of rebuilding
|
||||
// the outgoing Home list before leaving it.
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
if (!options?.background) tabs.select(tab)
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (!options?.background) ctx.projects.touch(directory)
|
||||
})
|
||||
},
|
||||
archive: async (session: SessionInfo) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} accentSubmit={props.workspace.selection.workspace()} />
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
|
||||
@@ -221,7 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
const current = () => {
|
||||
const current = createMemo(() => {
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => agent.current()?.model,
|
||||
@@ -229,7 +229,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
if (!item) return
|
||||
return models.find(item)
|
||||
}
|
||||
})
|
||||
|
||||
const configured = () => {
|
||||
const item = agent.current()
|
||||
|
||||
@@ -216,7 +216,6 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
accentSubmit: boolean
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
@@ -251,7 +250,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user