Compare commits

...
76 Commits
Author SHA1 Message Date
opencode-agent[bot] 0a46301e36 chore(core): refresh bundled models.dev snapshot 2026-10-04 13:59:27 +00:00
Shoubhit Dash c789aa16d1 refactor(ai): remove unused test code (#53126) 2026-10-04 17:07:07 +05:30
Shoubhit Dash a515e3bf4b refactor(ai): delete unregistered recording cassettes (#53127) 2026-10-04 17:06:42 +05:30
Shoubhit Dash 8380c864bf feat(plugin): add select dialog search and actions (#53009) 2026-10-04 16:10:27 +05:30
Shoubhit Dash 247eb5cc9a fix(tui): allow plugin keymaps during setup (#53010) 2026-10-04 15:46:34 +05:30
Luke Parker ecfa884ff8 feat(session-ui): show model variant in message meta (#53112) 2026-10-04 08:21:24 +00:00
Luke Parker 0d536d2d7d fix(gui-extensions): avoid startup round trip and keep renamed enable state (#53077) 2026-10-04 17:40:36 +10:00
Luke Parker 715de7af15 fix(gui-extensions): fix stale details, browser cleanup, update checks and panel state (#53075) 2026-10-04 17:21:04 +10:00
Luke Parker 077444b94b fix(app): keep side panel tabs when a session moves (#53081) 2026-10-04 16:59:52 +10:00
Luke Parker 5589ca43f1 refactor(gui-extensions): tighten the SDK surface (#53078) 2026-10-04 16:59:43 +10:00
Luke Parker 453dd2ad01 fix(app): load Windows-path markdown images (#53100) 2026-10-04 06:49:36 +00:00
Luke Parker 44579a5d98 test(app): mock session wait and parse e2e mock payloads (#53095) 2026-10-04 16:32:34 +10:00
Luke Parker f8cafd8362 fix(app): stop the browser address bar looking bold when selected (#53087) 2026-10-04 16:23:08 +10:00
Kit Langton 9155b7b791 fix(app): load component-test modules at the URLs Vite imports them by (#53091) 2026-10-04 16:06:48 +10:00
Kit Langton 9c66a0acb0 refactor(tui): remove unused plugin theme registration (#52992) 2026-10-03 21:50:55 -07:00
Kit Langton ee730bdd91 refactor(tui): remove unused V1 syntax theme generation (#52987) 2026-10-03 21:43:07 -07:00
Kit Langton cfbdefa041 refactor(tui): trim unused package exports (#52988) 2026-10-03 21:43:03 -07:00
Kit Langton 2884b49f8f refactor(tui): remove permanently disabled unshare command (#53003) 2026-10-03 21:34:00 -07:00
Kit Langton b79da2697a refactor(tui): drop unused ComposerTab.onClose and composer re-export (#53002) 2026-10-03 21:33:55 -07:00
Kit Langton 552d2b9c8a refactor(tui): drop never-passed component props (#53001) 2026-10-03 21:33:51 -07:00
Kit Langton 0d98734fb2 refactor(tui): share mini requestOptions helper (#53000) 2026-10-03 21:33:47 -07:00
Kit Langton 5ac5027cce refactor(tui): remove unused mini thinking override (#52999) 2026-10-03 21:33:42 -07:00
Kit Langton 4cb7ac9dab refactor(tui): drop constant mini frontend exit code (#52998) 2026-10-03 21:33:37 -07:00
Kit Langton bc499e4562 refactor(tui): remove unused monoTruncateMiddle (#52997) 2026-10-03 21:33:33 -07:00
Kit Langton fa373ddb51 refactor(tui): share clamp between tab pulse and fade-in text (#52996) 2026-10-03 21:33:28 -07:00
Kit Langton 2a02be02a7 refactor(tui): remove unused TabPulse outerFlashTail prop (#52995) 2026-10-03 21:33:23 -07:00
Kit Langton fc224dc8f0 refactor(tui): remove unused DialogSelect details and category view options (#52994) 2026-10-03 21:33:18 -07:00
Kit Langton eec8cf912e refactor(tui): remove unused Prompt hint, right, and showPlaceholder props (#52993) 2026-10-03 21:33:13 -07:00
Kit Langton 86f9edc107 refactor(tui): remove unread terminal environment fields (#52991) 2026-10-03 21:33:08 -07:00
Kit Langton 5d6804eaac refactor(tui): remove unused v1 config aliases and keybind values (#52990) 2026-10-03 21:33:03 -07:00
Kit Langton 697ca1c9d3 refactor(tui): drop unused theme re-exports (#52986) 2026-10-03 21:32:58 -07:00
Kit Langton 1287996a91 refactor(tui): drop stale terminal-win32 export (#52985) 2026-10-03 21:32:54 -07:00
Kit Langton a30f24355e refactor(tui): share file change status label (#53004) 2026-10-03 23:50:25 -04:00
Jérôme Benoit 96ecd60bdb fix(nix): provide desktop signing tools on darwin (#53007) 2026-10-03 22:33:12 -05:00
Aiden Cline 15c4e5d537 feat(ai): add Google Interactions protocol (#52981) 2026-10-03 22:22:58 -05:00
Aiden Cline 5617044dc9 refactor(ai): avoid redundant request body validation (#52909) 2026-10-03 22:22:15 -05:00
Luke Parker 0048c97c27 feat(gui-extensions): add typed composition and lifetime primitives (#52868) 2026-10-04 13:16:37 +10:00
opencode-agent[bot]andHona f72a103a4b fix(windows): hide background subprocess windows (#52871)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-10-04 11:20:47 +10:00
opencode-agent[bot] f74512bfe0 chore: update nix node_modules hashes 2026-10-03 23:05:19 +00:00
opencode-agent[bot]andneriousy 63b7de9434 fix(cli): restore Linux x64 musl PTY lock entry (#53035)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-10-04 00:55:38 +02:00
Filip e8c75c2de0 fix(cli): run the curl installer with Git Bash on Windows (#53029) 2026-10-04 00:29:09 +02:00
opencode-agent[bot] d1f8f5b35e chore: update nix node_modules hashes 2026-10-03 18:57:44 +00:00
Kit Langton 334f7a3510 fix(core): keep the first PTY output after spawn (#52960) 2026-10-03 11:51:01 -07:00
opencode-agent[bot] f02e184ba4 chore: update nix node_modules hashes 2026-10-03 14:32:42 +00:00
James Long 5ad0786536 fix(core): never let persistent PTY handoff break a server restart (#52573) 2026-10-03 10:23:31 -04:00
opencode-agent[bot] d8a3962923 chore(core): refresh bundled models.dev snapshot 2026-10-03 13:46:03 +00:00
Simon Klee 40679546d4 fix(tui): keep transcript rows stable when history is prepended (#52824)
Session rows have no id, so reconcile matched them by position. Each page of older history shifted every row into a different store object and remounted the visible transcript. Newly mounted markdown is blank until highlighting finishes, so the transcript blinked once per page. This was most visible with Low verbosity, where opening a session loads several pages to complete the oldest group.
2026-10-03 06:09:37 +00:00
Daniel Chen 12810e84d3 docs(www): add Fledge Alpha Free to Console models (#52896) 2026-10-02 21:59:24 -07:00
Aiden Cline 3e1aa1e58c fix(server): return 404 when a location folder is missing (#52668) 2026-10-02 23:43:56 -05:00
Aiden Cline 7ced9326e6 fix(ai): preserve Mistral and Cohere system parts (#52890) 2026-10-02 23:36:36 -05:00
Aiden Cline d1ff6f90cf fix(ai): preserve Gemini system text parts (#52888) 2026-10-02 23:04:49 -05:00
James Long d9d094a543 fix(tui): keep question form highlights on the raised surface (#52872) 2026-10-02 20:34:12 -04:00
Kit Langton d113a40f55 fix(core): rename legacy provider in top-level model (#51901) 2026-10-02 16:02:13 -07:00
Kit Langton 54956aac22 chore: enable noUnusedLocals in ai and core (#52858) 2026-10-02 22:45:27 +00:00
Kit Langton c1536734ce chore(cli): enable noUnusedLocals (#52857) 2026-10-02 22:45:05 +00:00
Kit Langton cf7fcfad0a chore(app): enable noUnusedLocals (#52856) 2026-10-02 22:44:02 +00:00
Kit Langton 17955a1100 refactor(core): remove duplicate v1 config migration (#51889) 2026-10-02 15:40:41 -07:00
Kit Langton c0c9c69a27 chore(tui): enable noUnusedLocals (#52851) 2026-10-02 22:36:03 +00:00
Kit Langton eb1a9ea4d9 chore: enable noUnusedLocals in schema, server, sdk and smaller packages (#52850) 2026-10-02 15:23:59 -07:00
Kit Langton 680bca3183 chore: enable noUnusedLocals in already-clean packages (#52849) 2026-10-02 15:23:50 -07:00
Kit Langton 22f81eed0b test(tui): drop title shimmer animation timing tests (#52848) 2026-10-02 15:23:31 -07:00
Aiden Cline 421a04f5fd fix(core): retry transient MCP connect failures (#52614) 2026-10-02 17:22:25 -05:00
Jérôme BenoitandTest User ec541abbcd chore(nix): run the nix eval workflow on v2 (#52143)
Co-authored-by: Test User <test@test.com>
2026-10-02 16:52:46 -05:00
Kit Langton 4c63a35dbe refactor(schema): drop unused FileAttachment re-export from SessionEvent (#52842) 2026-10-02 21:35:12 +00:00
Kit Langton 0e4dd7bfe9 refactor(ai): remove unused declarations in OpenAI chat protocol (#52840) 2026-10-02 21:33:58 +00:00
Kit Langton e28a624b8b refactor(core): remove unused git strategy in worktree layer (#52841) 2026-10-02 21:28:27 +00:00
Kit Langton 9277761af6 refactor(schema): remove unused QuestionV1 contract (#52838) 2026-10-02 21:28:13 +00:00
Kit Langton 0bef0eab67 refactor(core): remove legacy id prefix table (#52810) 2026-10-02 18:40:26 +00:00
Shoubhit Dash 66d15a7352 refactor(acp): split module seams and drop dead code (#52803) 2026-10-03 00:04:06 +05:30
Kit Langton 9f8551ab6b refactor(core): require job ids from their subject (#52806) 2026-10-02 18:27:13 +00:00
Kit Langton 8bcda4c718 refactor(core): name tool output files in ToolOutput (#52808) 2026-10-02 18:24:57 +00:00
Kit Langton ccd2aca07a test(core): match Code Mode description after allSettled change (#52812) 2026-10-02 18:14:27 +00:00
1391c0d52c docs(core): recommend settled results for independent calls (#52798)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-10-02 12:26:05 -05:00
Shoubhit Dash ec90ad50d3 fix(acp): settle permissions replied elsewhere and type request failures (#52774) 2026-10-02 22:43:32 +05:30
Shoubhit Dash 2a01ed60b1 perf(core): keep unchanged messages in request shaping (#52784) 2026-10-02 22:12:21 +05:30
Shoubhit Dash 80caae8ad1 test(acp): keep only spec and essential tests (#52775) 2026-10-02 22:01:49 +05:30
878 changed files with 27680 additions and 15699 deletions

No files matched your search

+11
View File
@@ -18,9 +18,20 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# A pull request checks out its merge into the current base; the first parent is that base.
fetch-depth: 2
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Run checks
run: bun run check
# Every GUI package file (app, desktop, gui-extensions, ui, session-ui) a pull request adds or edits must be free of
# oxlint problems, warn-level rules (anti-slop) included. Other packages are not affected.
- name: Lint changed files
if: github.event_name == 'pull_request'
# Against the merge's first parent, so base-branch commits the pull request has not merged are not counted as its
# changes (the event's base SHA can predate them).
run: bun run lint:changed HEAD^1
+2 -2
View File
@@ -2,9 +2,9 @@ name: nix-eval
on:
push:
branches: [dev]
branches: [dev, v2]
pull_request:
branches: [dev]
branches: [dev, v2]
workflow_dispatch:
concurrency:
+9
View File
@@ -252,6 +252,13 @@ jobs:
CI: true
timeout-minutes: 15
- name: Run app component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
run: bun --cwd packages/app test:components
env:
CI: true
timeout-minutes: 15
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -264,3 +271,5 @@ jobs:
packages/app/e2e/playwright-report
packages/session-ui/component-tests/test-results
packages/session-ui/component-tests/playwright-report
packages/app/component-tests/test-results
packages/app/component-tests/playwright-report
+52
View File
@@ -63,8 +63,60 @@
"anti-slop-effect/prefer-effect-match": "warn"
}
},
{
"files": ["packages/gui-extensions/src/*.ts", "packages/gui-extensions/src/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "solid-js",
"importNames": ["createEffect", "createRenderEffect", "createComputed"],
"message": "Extension code runs side effects through the SDK: createKeyed(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason."
}
]
}
]
}
},
{
"files": ["packages/gui-extensions/src/*/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "solid-js",
"importNames": ["createEffect", "createRenderEffect", "createComputed"],
"message": "Extension code runs side effects through the SDK: createKeyed(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason."
}
],
"patterns": [
{
"regex": "^@opencode/(app|desktop)(/|$)",
"message": "GUI extensions never import the app or desktop packages. Use the SDK."
},
{
"regex": "^@/",
"message": "GUI extensions never import app internals. Use the SDK."
},
{
"group": ["../*/*", "!../*/contract", "!../sdk/*"],
"message": "Import another extension only through its contract.ts."
},
{
"regex": "\\.css$",
"message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)."
}
]
}
]
}
},
{
"files": ["packages/gui-extensions/src/sdk/**"],
"rules": {
"no-restricted-imports": [
"error",
+11 -11
View File
@@ -122,7 +122,7 @@
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -355,7 +355,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
@@ -365,7 +365,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.8",
"bun-pty": "0.4.9",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -2167,19 +2167,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.2.0", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.2.0", "@opencode-ai/pty-darwin-x64": "0.2.0", "@opencode-ai/pty-linux-arm64-gnu": "0.2.0", "@opencode-ai/pty-linux-arm64-musl": "0.2.0", "@opencode-ai/pty-linux-x64-gnu": "0.2.0", "@opencode-ai/pty-linux-x64-musl": "0.2.0" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-pV86urAwinpwFXX8AJlOf8S9CVJhGsV+11I/J6TcxMW0c7u6LgeODzdj/BVBC6jUhsG2aKDxg8rACMcDCy3HiA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2y6xrktb2J7rRk+mzAJHA8cCbWhC4Lo2zJ66t9ad59qFhL5nzAPfpEOwnyvvFqhxebHNvL08Mp64M9IHnM0aiA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XVQwK9+KgVGYunCYPCCBf1Or0z6zSkzjgfdJd7dEe/LOFg5vmMkOfSB9dCXnoRCWGviWYk7xd07iFIFOgyj5Ig=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TPVGCQk5E65IY3ipcpd17rwKehdvXcXEYRhhGzok/6Dske69ei2S+ERz7NXZ8cKFAvbiiT771HHM+XyVYAl+7A=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iH6/liY7xN1OXVD9eGzdH11BVGvnpPsH5Z1Unz0Pn9EzkPFf28oDNKNyeXV0xIAP53oiYp8gpRq2PADNwbVsTg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GhdrmbUzxGHRfWvt1qutgVD4HLb7aSBsgWGjFsc6k8HjO5ZbdaC3ScRyd512YAAG5KAROLKZ6+0SEBsm5xFYlA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EbHchDsMmL5aOReIoo8NvQkKnyhyKmoL2RleF2JYF76va3FKuksmwsOBgQJh54i+QA4Fd0+9x54Q+2fJOzPiRQ=="],
"@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=="],
@@ -3549,7 +3549,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
+2
View File
@@ -40,6 +40,8 @@ stdenv.mkDerivation (finalAttrs: {
copyDesktopItems
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
darwin.cctools
darwin.sigtool
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
];
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
"x86_64-linux": "sha256-2RlbJRTEKSliuUbAE2lAktX63JFR/RKAuLkcCou8wb4=",
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
}
}
+2 -1
View File
@@ -18,7 +18,8 @@
"dev:www": "bun run --cwd services/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml",
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml && bun script/sdk-docs.ts",
"lint:changed": "bun script/lint-changed.ts",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
+25
View File
@@ -27,6 +27,31 @@ Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`
`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
### Google Interactions
`Google.configure({ apiKey }).interactions(modelID)` selects the Interactions API; `.model(modelID)` still selects
GenerateContent. The package entrypoint is `@opencode/ai/providers/google/interactions`.
```ts
const model = Google.configure({ apiKey }).interactions("gemini-3.8-flash")
const response = yield* LLM.generate({
model,
prompt: "Say hello.",
providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto", store: true },
})
```
Interactions supports text output, streamed function calls, native tool results, thought signatures, and multimodal
input. Full-history replay is the default (`store: false`); implicit caching works without retained interactions.
For server-side continuation, set `store: true` on the predecessor, read `interactionId` from the final event's
`providerMetadata.google`, and pass `previousInteractionId` on the next request with **only new messages**. Repeat
the system instructions and tool declarations on each request. Set `store: true` on each response you intend to
continue from. The package does not automatically select or persist continuation IDs.
Raw usage is preserved in `usage.providerMetadata.google`. `inputTokens` follows Google's top-level accounting;
`contextTokens` uses its full `raw_prompt_token` count when supplied. These can differ substantially with server-side
continuation. Explicit caches, hosted tools, and generated media are not supported by this initial protocol.
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
@@ -1,7 +1,7 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, ProviderShared } from "./shared.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
@@ -25,8 +25,6 @@ const WebExtractorItem = Schema.StructWithRest(
)
const Body = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, WebExtractorItem])),
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
enable_thinking: Options.fields.enableThinking,
previous_response_id: Options.fields.previousResponseId,
conversation: Options.fields.conversation,
@@ -52,7 +50,7 @@ export const protocol = Protocol.make({
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
const body = yield* OpenResponses.fromRequestWithAdapter(req, adapter)
const choice = body.tool_choice
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
return {
...body,
enable_thinking: opts.enableThinking,
previous_response_id: opts.previousResponseId,
@@ -62,7 +60,7 @@ export const protocol = Protocol.make({
typeof choice === "object" && choice.type === "function"
? { type: "allowed_tools" as const, mode: "required" as const, tools: [choice] }
: choice,
})
}
}),
},
stream: {
+9 -1
View File
@@ -124,7 +124,15 @@ const fromRequest = Effect.fn("CohereChat.fromRequest")(function* (request: LLMR
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const flattened = ProviderShared.flattenToolRequest(request)
const messages: (typeof Message.Type)[] = request.system.length
? [{ role: "system", content: ProviderShared.joinText(request.system) }]
? [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
: []
for (const message of flattened.request.messages) {
if (message.role === "system") {
+1 -1
View File
@@ -475,7 +475,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
tools: hasTools
? [
{
@@ -0,0 +1,561 @@
import { Effect, Schema } from "effect"
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 { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
ProviderID,
Usage,
type LLMRequest,
type ProviderMetadata,
type ToolResultPart,
} from "../schema/index.js"
import { Media } from "../media.js"
import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js"
import { encodeJson } from "../utils/json.js"
import { JsonObject, knownString, lenient, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MediaInput } from "./utils/media-input.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "google-interactions"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// =============================================================================
// Public Model Input
// =============================================================================
const ThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
const Options = Schema.Struct({
previousInteractionId: lenient(Schema.String),
store: lenient(Schema.Boolean),
thinkingLevel: lenient(ThinkingLevel),
thinkingSummaries: lenient(knownString<"auto" | "none">()),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
})
export type OptionsInput = typeof Options.Encoded
export type ProviderOptionsInput = OptionsInput
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
// =============================================================================
// Request Body Schema
// =============================================================================
const Text = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })
const MediaContent = Schema.Struct({
type: Schema.Literals(["image", "audio", "video", "document"]),
data: Schema.optional(Schema.String),
uri: Schema.optional(Schema.String),
mime_type: Schema.String,
})
const Content = Schema.Union([Text, MediaContent])
const InputStep = Schema.Union([
Schema.Struct({ type: Schema.Literals(["user_input", "model_output"]), content: Schema.Array(Content) }),
Schema.Struct({
type: Schema.Literal("thought"),
signature: Schema.optional(Schema.String),
summary: Schema.optional(Schema.Array(Text)),
}),
Schema.Struct({
type: Schema.Literal("function_call"),
id: Schema.String,
name: Schema.String,
arguments: Schema.Unknown,
signature: Schema.optional(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("function_result"),
call_id: Schema.String,
name: Schema.String,
result: Schema.Unknown,
is_error: Schema.optional(Schema.Boolean),
}),
])
type InputStep = typeof InputStep.Type
const ToolChoice = Schema.Union([
Schema.Literals(["auto", "any", "none"]),
Schema.Struct({ allowed_tools: Schema.Struct({ mode: Schema.Literal("any"), tools: Schema.Array(Schema.String) }) }),
])
const Body = Schema.Struct({
model: Schema.String,
input: Schema.Array(InputStep),
stream: Schema.Literal(true),
store: Schema.Boolean,
previous_interaction_id: Schema.optional(Schema.String),
system_instruction: Schema.optional(Schema.String),
service_tier: Schema.optional(Schema.String),
tools: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.Literal("function"),
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
}),
),
),
generation_config: Schema.Struct({
max_output_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
seed: Schema.optional(Schema.Number),
stop_sequences: Schema.optional(Schema.Array(Schema.String)),
thinking_level: Schema.optional(ThinkingLevel),
thinking_summaries: Schema.optional(Schema.String),
tool_choice: Schema.optional(ToolChoice),
}),
})
// =============================================================================
// Streaming Event Schema
// =============================================================================
const RawUsage = Schema.StructWithRest(
Schema.Struct({
total_input_tokens: optionalNull(Schema.Number),
total_cached_tokens: optionalNull(Schema.Number),
total_output_tokens: optionalNull(Schema.Number),
total_thought_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
raw_prompt_token: optionalNull(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type RawUsage = typeof RawUsage.Type
const OutputStep = Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(JsonObject),
signature: Schema.optional(Schema.String),
summary: Schema.optional(Schema.Array(Text)),
content: Schema.optional(Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) }))),
})
type OutputStep = typeof OutputStep.Type
const Delta = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("arguments_delta"), arguments: Schema.String }),
Schema.Struct({ type: Schema.Literal("thought_signature"), signature: Schema.String }),
Schema.Struct({ type: Schema.Literal("thought_summary"), content: Text }),
// Unknown output modalities must fail explicitly rather than disappearing from a successful response.
Schema.Struct({ type: Schema.String }),
])
const Interaction = Schema.StructWithRest(
Schema.Struct({
id: Schema.optional(Schema.String),
status: Schema.String,
usage: Schema.optional(RawUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const Event = Schema.Union([
Schema.Struct({ event_type: Schema.Literal("step.start"), index: Schema.Number, step: OutputStep }),
Schema.Struct({ event_type: Schema.Literal("step.delta"), index: Schema.Number, delta: Delta }),
Schema.Struct({ event_type: Schema.Literal("step.stop"), index: Schema.Number }),
Schema.Struct({
event_type: Schema.Literal("interaction.created"),
interaction: Schema.Struct({ id: Schema.optional(Schema.String) }),
}),
Schema.Struct({
event_type: Schema.Literal("interaction.status_update"),
interaction_id: Schema.optional(Schema.String),
status: Schema.String,
}),
Schema.Struct({ event_type: Schema.Literal("interaction.completed"), interaction: Interaction }),
Schema.Struct({ event_type: Schema.Literal("error"), error: Schema.Unknown }),
])
type Event = typeof Event.Type
// =============================================================================
// Parser State
// =============================================================================
interface ParserState {
readonly route: string
readonly metadataKey: string
readonly lifecycle: Lifecycle.State
readonly steps: Partial<Record<number, OutputStep>>
readonly tools: ToolStream.State<number>
readonly completed: boolean
}
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
// =============================================================================
// Request Body Construction
// =============================================================================
const mediaContent = Effect.fn("GoogleInteractions.mediaContent")(function* (asset: Media.Asset) {
if (
asset.kind !== "image" &&
asset.kind !== "audio" &&
asset.kind !== "video" &&
asset.mediaType !== "application/pdf" &&
asset.mediaType !== "text/csv"
)
return yield* ProviderShared.invalidRequest(
`Google Interactions does not support ${asset.mediaType} document input`,
)
const type: (typeof MediaContent.Type)["type"] =
asset.kind === "image" || asset.kind === "audio" || asset.kind === "video" ? asset.kind : "document"
const uri = MediaInput.refID(asset, ProviderID.make("google"))
if (uri !== undefined) return { type, uri, mime_type: asset.mediaType }
const inline = yield* ProviderShared.requireInlineMedia("Google Interactions", asset)
return { type, data: inline.base64, mime_type: inline.mime }
})
const signature = (metadata: ProviderMetadata | undefined, key: string) => {
const value = metadata?.[key]
return ProviderShared.isRecord(value) && typeof value.interactionSignature === "string"
? value.interactionSignature
: undefined
}
const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (request: LLMRequest) {
const steps: InputStep[] = []
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Google Interactions", message)
steps.push({ type: "user_input", content: [{ type: "text", text: part.text }] })
continue
}
const start = steps.length
// Consecutive ordinary content remains one native message; tools and thoughts retain their chronology.
const append = (content: typeof Content.Type) => {
const type = message.role === "assistant" ? "model_output" : "user_input"
const last = steps.at(-1)
if (steps.length > start && last?.type === type)
steps[steps.length - 1] = { type, content: [...last.content, content] }
else steps.push({ type, content: [content] })
}
for (const part of message.content) {
if (message.role === "tool") {
if (part.type !== "tool-result")
return yield* ProviderShared.unsupportedContent(ADAPTER, "tool", ["tool-result"])
steps.push({
type: "function_result",
call_id: part.id,
name: part.name,
result: yield* lowerToolResult(part),
is_error: part.result.type === "error" || undefined,
})
continue
}
if (part.type === "text") {
append({ type: "text", text: part.text })
continue
}
if (part.type === "media") {
append(yield* mediaContent(part.media))
continue
}
if (message.role === "assistant" && part.type === "reasoning") {
steps.push({
type: "thought",
signature: signature(part.providerMetadata, key),
summary: part.text ? [{ type: "text", text: part.text }] : undefined,
})
continue
}
if (message.role === "assistant" && part.type === "tool-call") {
steps.push({
type: "function_call",
id: part.id,
name: part.name,
arguments: part.input,
signature: signature(part.providerMetadata, key),
})
continue
}
return yield* ProviderShared.unsupportedContent(
ADAPTER,
message.role,
message.role === "user" ? ["text", "media"] : ["text", "media", "reasoning", "tool-call"],
)
}
}
return steps
})
const lowerToolResult = Effect.fn("GoogleInteractions.lowerToolResult")(function* (part: ToolResultPart) {
if (part.result.type === "json" && ProviderShared.isRecord(part.result.value)) return part.result.value
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
return yield* Effect.forEach(part.result.value, (item): Effect.Effect<typeof Content.Type, AIError> => {
if (item.type === "text") return Effect.succeed({ type: "text", text: item.text })
return mediaContent(ProviderShared.toolFileMedia(item).media)
})
})
const fromRequest = Effect.fn("GoogleInteractions.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const flattened = ProviderShared.flattenToolRequest(request)
if (flattened.tools.some((tool) => tool.native !== undefined))
return yield* ProviderShared.invalidRequest("Google Interactions hosted tools are not supported")
if (
request.generation?.topK !== undefined ||
request.generation?.frequencyPenalty !== undefined ||
request.generation?.presencePenalty !== undefined
)
return yield* ProviderShared.invalidRequest(
"Google Interactions does not support topK, frequencyPenalty, or presencePenalty",
)
const choice =
request.toolChoice === undefined
? undefined
: yield* ProviderShared.matchToolChoice(ADAPTER, request.toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "any" as const,
tool: (name) => ({ allowed_tools: { mode: "any" as const, tools: [name] } }),
})
return {
model: request.model.id,
input: yield* lowerMessages(flattened.request),
stream: true as const,
// Full-history replay need not create retained provider resources. Continuation callers opt in to storage.
store: options.store ?? false,
previous_interaction_id: options.previousInteractionId,
system_instruction: request.system.length ? ProviderShared.joinText(request.system) : undefined,
service_tier: options.serviceTier,
tools: flattened.tools.length
? flattened.tools.map((tool) => ({
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
}))
: undefined,
generation_config: {
max_output_tokens: request.generation?.maxTokens,
temperature: request.generation?.temperature,
top_p: request.generation?.topP,
seed: request.generation?.seed,
stop_sequences: request.generation?.stop,
thinking_level: options.thinkingLevel,
thinking_summaries: options.thinkingSummaries,
tool_choice: choice,
},
}
})
// =============================================================================
// Stream Parsing
// =============================================================================
const metadata = (state: ParserState, step: OutputStep): ProviderMetadata => ({
[state.metadataKey]: { interactionSignature: step.signature },
})
const mapUsage = (usage: RawUsage | undefined, key: string) => {
if (!usage) return undefined
const input = usage.total_input_tokens ?? undefined
const cached = usage.total_cached_tokens ?? undefined
const reasoning = usage.total_thought_tokens ?? undefined
const output =
usage.total_output_tokens === undefined || usage.total_output_tokens === null
? undefined
: usage.total_output_tokens + (reasoning ?? 0)
return new Usage({
contextTokens: usage.raw_prompt_token ?? input,
inputTokens: input,
outputTokens: output,
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
cacheReadInputTokens: cached,
reasoningTokens: reasoning,
totalTokens: usage.total_tokens ?? undefined,
providerMetadata: { [key]: usage },
})
}
const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
state: ParserState,
index: number,
step: OutputStep,
) {
const events: LLMEvent[] = []
let lifecycle = Lifecycle.stepStart(state.lifecycle, events)
let tools = state.tools
const id = String(index)
if (step.type === "thought") {
lifecycle = Lifecycle.reasoningStart(lifecycle, events, id, metadata(state, step))
for (const part of step.summary ?? []) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, id, part.text)
} else if (step.type === "model_output") {
lifecycle = Lifecycle.textStart(lifecycle, events, id)
for (const part of step.content ?? []) {
if (part.type !== "text")
return yield* ProviderShared.eventError(
ADAPTER,
`Unsupported Interactions output: ${part.type}`,
encodeJson(step),
)
if (part.text) lifecycle = Lifecycle.textDelta(lifecycle, events, id, part.text)
}
} else if (step.type === "function_call") {
if (!step.id || !step.name)
return yield* ProviderShared.eventError(ADAPTER, "Interactions function call lacks id or name", encodeJson(step))
tools = ToolStream.start(tools, index, {
id: step.id,
name: step.name,
providerMetadata: metadata(state, step),
input: step.arguments && Object.keys(step.arguments).length ? encodeJson(step.arguments) : "",
})
events.push(LLMEvent.toolInputStart({ id: step.id, name: step.name, providerMetadata: metadata(state, step) }))
} else
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions step: ${step.type}`, encodeJson(step))
return [{ ...state, lifecycle, tools, steps: { ...state.steps, [index]: step } }, events] satisfies StepResult
})
const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
state: ParserState,
index: number,
delta: typeof Delta.Type,
) {
const step = state.steps[index]
if (!step)
return yield* ProviderShared.eventError(ADAPTER, "Interactions delta without step.start", encodeJson(delta))
const events: LLMEvent[] = []
if (delta.type === "text" && "text" in delta && step.type === "model_output")
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, String(index), delta.text) },
events,
] satisfies StepResult
if (delta.type === "thought_summary" && "content" in delta && step.type === "thought")
return [
{ ...state, lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, String(index), delta.content.text) },
events,
] satisfies StepResult
if (delta.type === "thought_signature" && "signature" in delta) {
const next = { ...step, signature: delta.signature }
const tool = state.tools[index]
return [
{
...state,
steps: { ...state.steps, [index]: next },
tools: tool ? { ...state.tools, [index]: { ...tool, providerMetadata: metadata(state, next) } } : state.tools,
},
events,
] satisfies StepResult
}
if (delta.type === "arguments_delta" && "arguments" in delta && step.type === "function_call") {
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
index,
delta.arguments,
"Interactions arguments without function call",
)
if (ToolStream.isError(result)) return yield* result
return [{ ...state, tools: result.tools }, result.events] satisfies StepResult
}
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions delta: ${delta.type}`, encodeJson(delta))
})
const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserState, index: number) {
const step = state.steps[index]
if (!step) return yield* ProviderShared.eventError(ADAPTER, "Interactions step.stop without step.start")
const events: LLMEvent[] = []
if (step.type === "thought")
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, String(index), metadata(state, step)) },
events,
] satisfies StepResult
if (step.type === "model_output")
return [
{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, String(index)) },
events,
] satisfies StepResult
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
return [{ ...state, tools: result.tools }, result.events ?? []] satisfies StepResult
})
const step = Effect.fn("GoogleInteractions.step")(function* (state: ParserState, event: Event) {
switch (event.event_type) {
case "step.start":
return yield* onStart(state, event.index, event.step)
case "step.delta":
return yield* onDelta(state, event.index, event.delta)
case "step.stop":
return yield* onStop(state, event.index)
case "interaction.created":
case "interaction.status_update":
return [state, []] satisfies StepResult
case "error":
return yield* new AIError({
reason: classifyProviderFailure({
message: providerErrorMessage(encodeJson(event)) ?? "Google Interactions stream error",
data: event.error,
rawBody: encodeJson(event),
}),
})
case "interaction.completed": {
const interaction = event.interaction
if (interaction.status === "failed" || interaction.status === "cancelled")
return yield* new AIError({
reason: classifyProviderFailure({
message: `Google Interactions ${interaction.status}`,
data: interaction,
rawBody: encodeJson(event),
}),
})
if (!["completed", "requires_action", "incomplete"].includes(interaction.status))
return yield* ProviderShared.eventError(
ADAPTER,
`Unexpected terminal Interactions status: ${interaction.status}`,
encodeJson(event),
)
const pending = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events = [...pending.events]
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized:
interaction.status === "requires_action"
? "tool-calls"
: interaction.status === "incomplete"
? "length"
: "stop",
raw: interaction.status,
},
usage: mapUsage(interaction.usage, state.metadataKey),
providerMetadata: { [state.metadataKey]: { interactionId: interaction.id } },
})
return [{ ...state, lifecycle, tools: pending.tools, completed: true }, events] satisfies StepResult
}
}
})
// =============================================================================
// Protocol And Route
// =============================================================================
export const protocol = Protocol.make({
id: ADAPTER,
sanitizer: "gemini",
body: { schema: Body, from: fromRequest },
stream: {
event: Protocol.jsonEvent(Event),
initial: (request): ParserState => ({
route: `${request.model.provider}/${ADAPTER}`,
metadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
lifecycle: Lifecycle.initial(),
steps: {},
tools: ToolStream.empty<number>(),
completed: false,
}),
step,
terminal: (event) => event.event_type === "interaction.completed",
onHalt: (state) =>
state.completed
? Effect.succeed([])
: Effect.fail(
ProviderShared.eventError(ADAPTER, "Google Interactions stream ended before interaction.completed"),
),
},
})
export const route = Route.make({
id: ADAPTER,
provider: "google",
providerMetadataKey: "google",
protocol,
endpoint: Endpoint.path("/interactions", { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
framing: Framing.sse,
})
export * as GoogleInteractions from "./google-interactions.js"
+1
View File
@@ -2,6 +2,7 @@ export * as AnthropicMessages from "./anthropic-messages.js"
export * as BedrockConverse from "./bedrock-converse.js"
export * as CohereChat from "./cohere-chat.js"
export * as Gemini from "./gemini.js"
export * as GoogleInteractions from "./google-interactions.js"
export * as MistralChat from "./mistral-chat.js"
export * as OpenAIChat from "./openai-chat.js"
export * as OpenAIImages from "./openai-images.js"
+5 -27
View File
@@ -1,6 +1,5 @@
import { Effect, Encoding, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
@@ -44,13 +43,6 @@ const ImageItem = Schema.Struct({
error: Schema.optional(Schema.Unknown),
})
const Body = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, ImageItem])),
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
stream: Schema.Literal(true),
})
const MessageAnnotations = Schema.Struct({
content: Schema.Array(Schema.Struct({ annotations: optionalArray(JsonObject) })),
})
@@ -62,12 +54,13 @@ interface ParserState extends OpenResponses.ParserState {
const adapter = {
id: ADAPTER,
name: NAME,
nativeTool: (native) => ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(native.meta),
restoreHostedToolItem: (item: unknown) => (Schema.is(ImageItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: LLMRequest) {
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
const projected = ProviderShared.flattenToolRequest(
return yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, {
messages: request.messages.map((message) =>
Message.make({
@@ -93,23 +86,8 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
}),
),
}),
adapter,
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
...(yield* OpenResponses.lowerConversation(projected.request, adapter)),
...OpenResponses.lowerGeneration(request),
tools:
projected.tools.length === 0
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
Effect.gen(function* () {
if (tool.native === undefined) return yield* OpenResponses.lowerTool(NAME, tool)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
}),
),
tool_choice:
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* OpenResponses.lowerToolChoice(NAME, request.toolChoice) : undefined),
})
})
const HOSTED_TOOLS = {
@@ -222,7 +200,7 @@ const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, inpu
export const protocol = Protocol.make({
id: ADAPTER,
body: { schema: Body, from: fromRequest },
body: { schema: OpenResponses.OpenResponsesBody, from: fromRequest },
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request): ParserState => ({ ...OpenResponses.initial(request, adapter), completedItems: new Set() }),
@@ -231,6 +209,6 @@ export const protocol = Protocol.make({
},
})
export const httpTransport = HttpTransport.sseJson.with<Schema.Schema.Type<typeof Body>>()
export const httpTransport = OpenResponses.httpTransport
export * as MetaResponses from "./meta-responses.js"
+15 -2
View File
@@ -68,7 +68,10 @@ const MistralAssistantToolCall = Schema.Struct({
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
}),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
@@ -335,7 +338,17 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
request.system.length === 0
? []
: [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
+29 -30
View File
@@ -168,10 +168,20 @@ export const ConfigurationUpdate = Schema.Struct({
type: Schema.Literal("configuration_update"),
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
})
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
export type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
export const HostedToolReplay = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
id: Schema.String,
}),
[JsonObject],
)
export type HostedToolReplayItem = Schema.Schema.Type<typeof HostedToolReplay>
export const InputItem = Schema.Union([
CompactionItem,
ConfigurationUpdate,
Schema.Struct({ type: Schema.tag("message"), role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ type: Schema.tag("message"), role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({
@@ -204,24 +214,9 @@ export const InputItem = Schema.Union([
output: OpenResponsesFunctionCallOutput,
}),
HostedToolItem,
HostedToolReplay,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ConfigurationUpdate
| {
readonly type: "message"
readonly id?: string
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
}
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
@@ -239,6 +234,14 @@ export const Tool = Schema.Struct({
strict: Schema.optional(Schema.Boolean),
})
export const HostedTool = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
}),
[JsonObject],
)
export type HostedTool = Schema.Schema.Type<typeof HostedTool>
export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
@@ -257,7 +260,7 @@ export const coreFields = {
model: Schema.String,
input: Schema.Array(InputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(Tool),
tools: optionalArray(Schema.Union([Tool, HostedTool])),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -292,7 +295,7 @@ export const coreFields = {
frequency_penalty: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
export const OpenResponsesBody = Schema.Struct({
...coreFields,
stream: Schema.Literal(true),
})
@@ -397,9 +400,7 @@ export const decodeChannelEvent = (frame: string) =>
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly nativeTool?: (
native: NonNullable<ToolDefinition["native"]>,
) => Effect.Effect<{ readonly type: string }, AIError>
readonly nativeTool?: (native: NonNullable<ToolDefinition["native"]>) => Effect.Effect<HostedTool, AIError>
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: Media.Inline | undefined
@@ -455,8 +456,10 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protoco
})
export const lowerTools = (tools: ReadonlyArray<ToolDefinition>, adapter: ProviderAdapter) =>
Effect.forEach(tools, (tool) =>
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
Effect.forEach(
tools,
(tool): Effect.Effect<Schema.Schema.Type<typeof Tool> | HostedTool, AIError> =>
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
)
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -592,7 +595,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const input: OpenResponsesInputItem[] = []
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
@@ -827,11 +830,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
}
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
export const fromRequest = (request: LLMRequest) => fromRequestWithAdapter(request, BASE_ADAPTER)
// =============================================================================
// Stream Parsing
-7
View File
@@ -14,7 +14,6 @@ import {
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type LLMRequest,
@@ -46,12 +45,6 @@ const OpenAIChatCacheControl = Schema.Struct({
})
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
})
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
+3 -12
View File
@@ -103,15 +103,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
OpenResponses.InputItem,
OpenAIResponsesHostedToolItem,
OpenResponses.ConfigurationUpdate,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
@@ -134,7 +127,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, CompactionTrigger])),
})
const adapter = {
@@ -202,15 +195,13 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
: { type: "function" as const, name },
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = OpenResponsesOptions.resolve(request)
const updates = resolveEffortUpdates(request, options.reasoningEffort)
return yield* decodeBody({
return {
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
@@ -220,7 +211,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
? undefined
: (OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)),
})
}
})
const checkpointBody = {
+2 -9
View File
@@ -31,19 +31,12 @@ const XAIResponsesHostedToolItem = Schema.Union([
),
])
const XAIResponsesBody = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
stream: Schema.Literal(true),
})
const adapter = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.unsupportedOperation({
@@ -53,7 +46,7 @@ const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LL
message:
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* OpenResponses.fromRequestWithAdapter(request, adapter)
})
const HOSTED_TOOLS = {
@@ -83,7 +76,7 @@ const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: XAIResponsesBody,
schema: OpenResponses.OpenResponsesBody,
from: fromRequest,
},
stream: {
+12 -2
View File
@@ -5,6 +5,7 @@ import { MediaRoute } from "../route/media.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { Gemini } from "../protocols/gemini.js"
import { GoogleInteractions } from "../protocols/google-interactions.js"
import { GoogleImages } from "../protocols/google-images.js"
import { GoogleSpeech } from "../protocols/google-speech.js"
import { GoogleTranscription } from "../protocols/google-transcription.js"
@@ -16,15 +17,16 @@ export type { GoogleTranscriptionOptions } from "../protocols/google-transcripti
export type { GoogleVideoOptions } from "../protocols/google-video.js"
export type GeminiOptionsInput = Gemini.OptionsInput
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
export type GoogleInteractionsOptionsInput = GoogleInteractions.OptionsInput
export const id = ProviderID.make("google")
export const routes = [Gemini.route]
export const routes = [Gemini.route, GoogleInteractions.route]
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
readonly providerOptions?: Gemini.ProviderOptionsInput & GoogleInteractions.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
@@ -45,12 +47,19 @@ const configuredRoute = (input: Config) => {
return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
}
const interactionsRoute = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return GoogleInteractions.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
const media = MediaRoute.deployment(input, auth(input))
return {
id,
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
interactions: (modelID: string | ModelID) =>
interactionsRoute(input).model<GoogleInteractions.ProviderOptionsInput>({ id: modelID }),
image: (modelID: string | ModelID) => GoogleImages.model({ ...media, id: modelID }),
video: (modelID: string | ModelID) => GoogleVideo.model({ ...media, id: modelID }),
speech: (modelID: string | ModelID) => GoogleSpeech.model({ ...media, id: modelID }),
@@ -73,6 +82,7 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
}).model(modelID)
export const image = provider.image
export const interactions = provider.interactions
export const video = provider.video
export const speech = provider.speech
export const transcription = provider.transcription
@@ -0,0 +1,21 @@
import { configure } from "../google.js"
import type { ProviderPackage } from "../../provider-package.js"
import type { GoogleInteractions } from "../../protocols/google-interactions.js"
export type Settings = ProviderPackage.Settings &
GoogleInteractions.ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const model: ProviderPackage.Definition<Settings, GoogleInteractions.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).interactions(modelID)
+2 -5
View File
@@ -358,7 +358,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared> {
const protocol = input.protocol
const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
const decodeEvent = (route: string) => (frame: Frame) =>
decodeEventEffect(frame).pipe(
@@ -417,7 +416,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
request,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
encodeBody: ProviderShared.encodeJson,
middleware: options?.http,
webSocket: options?.webSocket,
}),
@@ -576,9 +575,7 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options
const resolved = prepareRequest(request)
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const body = yield* route.body.from(resolved)
const prepared = yield* route.prepareTransport(body, resolved, options)
return {
-1
View File
@@ -14,7 +14,6 @@ import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
import * as GoogleVertexMessages from "../src/providers/google-vertex-messages.js"
import * as GoogleVertexResponses from "../src/providers/google-vertex-responses.js"
import * as OpenAI from "../src/providers/openai.js"
import * as OpenAICompatible from "../src/providers/openai-compatible.js"
import * as OpenRouter from "../src/providers/openrouter.js"
import * as XAI from "../src/providers/xai.js"
+1 -2
View File
@@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message, ToolCallPart } from "../src/index.js"
import { LLM, Message, ToolCallPart } from "../src/index.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
import { Gemini } from "../src/protocols/gemini.js"
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { applyCachePolicy } from "../src/cache-policy.js"
import { applyEffortUpdates } from "../src/effort-updates.js"
import { it, testEffect } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch",
"recordedAt": "2026-05-05T20:09:16.245Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SikJVFaMR1XLMtavUhvuog\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently 72°F.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":14} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -1,56 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/claude-opus-4-7-drives-a-tool-loop",
"recordedAt": "2026-05-03T19:59:44.186Z",
"tags": [
"prefix:anthropic-messages",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-loop",
"golden",
"flagship"
]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01DgAEgLgB1ZhavZon4qGE1t\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Pa\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":66} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_011KJqj32QjkrUAiBFxhmEoG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris is curr\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ently sunny at 22°C.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/rejects-malformed-assistant-tool-order-without-patch",
"recordedAt": "2026-05-05T20:08:42.597Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool", "sad-path"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011Cak2XdJgnzxKCY2BC2Beh\"}"
}
}
]
}
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/streams-text",
"recordedAt": "2026-04-28T21:18:45.535Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01UodR8c3ezAK8rAfi8HAs8g\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/streams-tool-call",
"recordedAt": "2026-04-28T21:18:46.878Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01RYgU7NUPMK4B9v8S7gVpCS\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_012rmAruviySvUXSjgCPWVRu\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -6,7 +6,7 @@
"provider:cohere"
],
"name": "cohere/continues-a-native-tool-call",
"recordedAt": "2026-10-02T02:36:16.143Z"
"recordedAt": "2026-10-03T04:09:51.600Z"
},
"interactions": [
{
@@ -17,14 +17,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"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}}}],\"max_tokens\":2048}"
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"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}}}],\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"a57af21c-0496-4123-8e83-bcc6abb9bcae\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" about\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" access\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" say\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" use\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: conteLine truncated
"body": "event: message-start\ndata: {\"id\":\"d6c0d412-926c-42ed-b152-043404f8c33c\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" available\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" specifically\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" mentioned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" as\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: conteLine truncated
}
},
{
@@ -35,14 +35,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking about the weather in Paris. I have access to a get_weather function that takes a city parameter. The instructions say to use the get_weather tool exactly once, and then reply exactly \\\"Paris is sunny\\\" after getting the tool result.\\n\\nLet me call the get_weather function with \\\"Paris\\\" as the city parameter.\"}],\"tool_calls\":[{\"id\":\"get_weather_mdhvw7kbf1e3\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_mdhvw7kbf1e3\"}],\"stream\":true,\"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}}}],\"max_tokens\":2048}"
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking for the weather in Paris. I have a get_weather function available that takes a city parameter. The user specifically mentioned \\\"Paris\\\" as the city. I need to use the get_weather tool exactly once as per the policies, and I have all the required parameters (city = \\\"Paris\\\").\\n\\nLet me call the function with the city parameter set to \\\"Paris\\\".\"}],\"tool_calls\":[{\"id\":\"get_weather_vfp5ryredwrg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_vfp5ryredwrg\"}],\"stream\":true,\"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}}}],\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"fd1ac100-b2c5-42f4-ae1c-6de65aa5ef6a\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" has\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" with\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" of\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" According\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" after\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" getting\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" need\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" reply\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: cLine truncated
"body": "event: message-start\ndata: {\"id\":\"0c6cba0e-442d-4e28-96b2-c3cd6ff3b22d\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" returned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" data\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" (\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"presumably\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Celsius\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\")\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Condition\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n\\nI\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" should\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" present\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" this\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" information\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\Line truncated
}
}
]
@@ -0,0 +1,66 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-interactions",
"provider:google",
"protocol:google-interactions"
],
"name": "google-interactions/continues-tool-results-with-previous-interaction-id",
"recordedAt": "2026-10-03T18:10:17.900Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"store\":true,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"tool_choice\":\"any\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: interaction.created\ndata: {\"interaction\":{\"id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"interaction_id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EoACCv0BAWkUfRMR9cx79IeL9V4HvDAHann4OEdOjz7FebGRwlCFGJHqLGG1lG1d+9xzzyPXN0d60gjMwbeix1bE4cCckDeMV0vAkOfR/epPhvmcfI+vWK1nURu+3UcgUBQ9on4VlZFcnOg7SoihuvxuQB4qqT65EAyqi8ybd3P8NB+V/FNcTBvMmJuNNCNxI5KgwsHjdT+xLdssTieljIG3H99rSz3/Nv90dYEJIr0WsQ1a3ZevpD6gPts/8Id/Z92zQ3aq4LAr47rdItCVF5h63Lchi9QFrav5LGf6lNVAOnlHIyXBrlAnVbdLsAdzlemlHY+oIWNMncL/NKayCt+qvQ==\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"id\":\"call_326996\",\"signature\":\"AWkUfRMR9cx79IeL9V4HvDAHann4OEdOjz7FebGRwlCFGJHqLGG1lG1d+9xzzyPXN0d60gjMwbeix1bE4cCckDeMV0vAkOfR/epPhvmcfI+vWK1nURu+3UcgUBQ9on4VlZFcnOg7SoihuvxuQB4qqT65EAyqi8ybd3P8NB+V/FNcTBvMmJuNNCNxI5KgwsHjdT+xLdssTieljIG3H99rSz3/Nv90dYEJIr0WsQ1a3ZevpD6gPts/8Id/Z92zQ3aq4LAr47rdItCVF5h63Lchi9QFrav5LGf6lNVAOnlHIyXBrlAnVbdLsAdzlemlHY+oIWNMncL/NKayCt+qvQ==\",\"type\":\"function_call\",\"name\":\"get_weather\",\"arguments\":{}},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"arguments_delta\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"status\":\"requires_action\",\"usage\":{\"total_tokens\":114,\"total_input_tokens\":68,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":68}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":30,\"raw_prompt_token\":107,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":30}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}],\"thoughts_tokens_details\":[{\"modality\":\"text\",\"tokens\":30}]}]},\"created\":\"2026-10-03T18:10:16Z\",\"updated\":\"2026-10-03T18:10:16Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"function_result\",\"call_id\":\"call_326996\",\"name\":\"get_weather\",\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"stream\":true,\"store\":false,\"previous_interaction_id\":\"v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE\",\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"tool_choice\":\"none\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TchP60hnCGy9QgdyJKikXfeP9DBx/BVTE92JiMUcdYnAQ7vGOt2zL8v0GQMbCshndVE0+WeHt0QXyzeeMeNFH/UQ5bxo3Vs+T3H1e6Fq3LFIjDuvYvMYMRLk44oULO1EhoCg=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"The weather in Paris\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" is sunny with a temperature of 22°C.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":115,\"total_input_tokens\":99,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":99}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":134,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":134}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":134}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}]},\"created\":\"2026-10-03T18:10:17Z\",\"updated\":\"2026-10-03T18:10:17Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "DELETE",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions/v1_ChdCMFhCYXRqeURhVDdxdHNQNWJLTXNBURIXQjBYQmF0anlEYVQ3cXRzUDViS01zQVE",
"headers": {},
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{}"
}
}
]
}
@@ -0,0 +1,50 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-interactions",
"provider:google",
"protocol:google-interactions"
],
"name": "google-interactions/replays-native-tool-results-and-signatures-statelessly",
"recordedAt": "2026-10-03T18:10:15.040Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"store\":false,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"thinking_summaries\":\"auto\",\"tool_choice\":{\"allowed_tools\":{\"mode\":\"any\",\"tools\":[\"get_weather\"]}}}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TU3VE2ypZTy8W0/W4ikeJNHd8J/UqUDCBIILV/U6sCJUEUat3oneLPVOSvuy+mLi2ByVh2ZlkcxEiY4DsOcsHKSF6q1WBk9YuCqE+W8BMNjZoRwBQeyDeq6cJpXDVkFtBYbg=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"id\":\"call_1850980\",\"signature\":\"AWkUfRNTdUTbKllPLxbT9biKR4k0d3wn9SpQMIEggtX9TqwIlQRRq3eid4s9U5K+7L6YuLYHJWHZmWRzESJjgOw5ywcpIXqrVYGT1i4KoT5bwEw2NmhHAFB7IN6rpwmlcNWQW0FhuA==\",\"type\":\"function_call\",\"name\":\"get_weather\",\"arguments\":{}},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"arguments_delta\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"requires_action\",\"usage\":{\"total_tokens\":84,\"total_input_tokens\":68,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":68}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":107,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":107}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":22}]}]},\"created\":\"2026-10-03T18:10:14Z\",\"updated\":\"2026-10-03T18:10:14Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"thought\",\"signature\":\"EmkKZwFpFH0TU3VE2ypZTy8W0/W4ikeJNHd8J/UqUDCBIILV/U6sCJUEUat3oneLPVOSvuy+mLi2ByVh2ZlkcxEiY4DsOcsHKSF6q1WBk9YuCqE+W8BMNjZoRwBQeyDeq6cJpXDVkFtBYbg=\"},{\"type\":\"function_call\",\"id\":\"call_1850980\",\"name\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"},\"signature\":\"AWkUfRNTdUTbKllPLxbT9biKR4k0d3wn9SpQMIEggtX9TqwIlQRRq3eid4s9U5K+7L6YuLYHJWHZmWRzESJjgOw5ywcpIXqrVYGT1i4KoT5bwEw2NmhHAFB7IN6rpwmlcNWQW0FhuA==\"},{\"type\":\"function_result\",\"call_id\":\"call_1850980\",\"name\":\"get_weather\",\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"stream\":true,\"store\":false,\"system_instruction\":\"Use get_weather for weather questions. Answer concisely after receiving the result.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\",\"thinking_summaries\":\"auto\",\"tool_choice\":\"none\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TRPN/gVeX86bn3+mP2Y1jv3jX464uSnSB+RuBqyig1Xfg52RBzk5GzUt98niutpbttYfMLkXURehjrP1AJUJzQbGLMqmqKD5iEp3bgLq46B3Ov+RcO539Fcz6+qCpPx+VaMQ=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"The weather in Paris\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\" is sunny with a temperature of 22°C.\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":108,\"total_input_tokens\":92,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":92}],\"total_cached_tokens\":0,\"total_output_tokens\":16,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":104,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":104}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":104}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":20}]}]},\"created\":\"2026-10-03T18:10:15Z\",\"updated\":\"2026-10-03T18:10:15Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-interactions",
"provider:google",
"protocol:google-interactions"
],
"name": "google-interactions/streams-reasoning-and-retains-thought-signatures",
"recordedAt": "2026-10-03T18:08:37.674Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.\"}]}],\"stream\":true,\"store\":false,\"generation_config\":{\"max_output_tokens\":4096,\"thinking_level\":\"high\",\"thinking_summaries\":\"auto\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"content\":{\"text\":\"**Analyzing Congruences**\\n\\nI'm tackling a system of congruences to find the smallest positive integer x. Currently, I'm reviewing the Chinese Remainder Theorem as a potential solution, or considering a direct congruence-based approach. The congruences are as follows: $x \\\\equiv 1 \\\\pmod{7}$, $x \\\\equiv 2 \\\\pmod{9}$, $x \\\\equiv 3 \\\\pmod{11}$. I will use these congruences to find a solution.\\n\\n\\n\",\"type\":\"text\"},\"type\":\"thought_summary\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"content\":{\"text\":\"**Combining Congruences**\\n\\nI've made progress by combining the first two congruences. I determined that x is congruent to 29 modulo 63. Now, I am focused on the remaining congruence, attempting to solve for k in the expression 63k + 29 ≡ 3 (mod 11). I simplified the expression to 8k ≡ 7 (mod 11) and am looking to multiply both sides to eliminate the coefficient of k.\\n\\n\\n\",\"type\":\"text\"},\"type\":\"thought_summary\"},\"event_type\":\"step.delta\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EskbCsYbAWkUfRM84T8fIy7KmpVRW3jt54Qo5USy0D9bJVmz6itgVBPMK3oD6Lx2AlpdFNnJ4oKCuDmIBs7ok77Tx10Ou1qfT0CsnJ391kx2pc1xAKdJJxvv2nBkHzVWb2/BORG0YNsdE96ulew1vC6x4mu/33XglL06m1hNhK8DPXGdn5lWMhKOqb9L9oBhMxazHUMlwStX67RdjURSVw/lodq/6Ao6PPTguHE9gEyh/iWwALZJCKkZw0LNgdWSRa/Ke4tfr8bncWcIGqz8bmxlaSUVB70m3o7TqvX6l4OyxEG4EwI8ixAKsxIicmxzzIqtGlWGqV/1ahnfIEktee0gF+cH/LcrBjUDblnOGUeKNCZlz7MBVIoRlWx5al5xcX1Vb63SekRlAuBqHqdEioYvMwm52QXzeXfNMDf+ixMhtpQ2fpAjG7PM+D0y+m2V9+3iUI/63e44RjWW2kZhgLrCQ9KSWrRoSsohGVS9zvGQBaVc6vb1QyX56A80eLKbqcGhWSHc+gp1L2lCM1MwBsvl9wM+Szzs8O61UBQJD4C/Pvyed1hxwt0Ebku70VyfTPJGLufWDisMOrruBmQ8MU+OyFk4N1Qm2ZkhOw2tE3yi9lf2ih0mfuj2a/nj81GIbvbSA+r+Oh3lhgOX7IlzidD6BJgSgWY4bG83a1twEVJyvBKZBIIDYKumDSz7tX9gWeKa5b90+RKGohyeM4Kp3yxuPGDPW6jxSHa6hch837OagyVjO/EVkB1LruWIAVAvFG9oAPYNesB2xFeeseXCnAQaNYbw6KMkH85kNBREvFZU1QpFHFWQ9gAxWfSTxCjnkqGao1In/b7wQVopgMVthrCr5VScf8HArkDdD8R9pnN9iD6he0mjmsFnw3mA80KxcWugmKiYkWrIfpEdbNB5FsyCffW+Wt9WmmFnH168qkhbkp5uXk3s8/bJFKmeijyNVWIG9LlLnZ6tpDkq8UqZSriapLq2byI1fk+Mu3UszAeen4qSDXQ32xGqKSr+SG/qpqVlFrvYr2PePjaZUljSqrH0CQSdqMvmlnM9E3XO4rQNtKcmg14BZ4FpVh7m/kCM0zfCfjSdX/BwWibEV4loM+eqLYyX7aul+chBDiQeO3iGkGWkfpycax2NDTYEMfimjuAJlWXsAuhJcdzLdeRucaZwJ0Fwpo0P153I8GIo7AQckiVaeBZRBY9fTEoz8khALMGRGSMrFDqqJ+1dDVdNxAh2txUeOneNi/D0rfM1W4yLJaB6Djl48VHoM1DguwSNDj4yE32Ku4XpXasoSd5/jRmmT8FKvk+FF4jxbJZcYHyTLcnyFCvDfrSBqdJAbdaLgkvRz9Gl1JAs3WPKR+CvIjbuf1cWxj8V8fyngf239aHVfePTXFXPi+M7uRo+923rjW7XxJortd68jHa7GpaLGUKVXgtKBXkAKCrRNRRwIJjODieyGYkj+GdUE0ikYCwbUPx8ZamGY3hjP8GjaVHK+9pUP7CddlUn3H3lIFbcXxQUtCOEFmwg9s9JcJSWLaoX0Vg0KIC1hYtamEkdiJSDbQWURPgzTD/llV86iDx/yaDgXWxaT4DkyNRfVcK2AFiDHQjHhRGd8KDizdpyCfAJZwTlF/nAPz4/FA1/bQ/cT5MHuebzbWbzkYDkDBzTXp2ND6VF50a3XUoKGV3LC6AEsUrUIDksKatHh0hLe+j/YMwOluyvI1WqfAHjCnAqjl/l29S/gFpAKMMjSS60OQzgp/hhORd+gDb8vj/9AS19B8uowIz8KnJkMHv57W3CKPROz2gBMl1VQYm5rRIayBn1fEtpwldDScyhFNtdx7G/ypjQLpgVqzdZTy1dZqHJwA1lOtoMfG1kz/SIDxF56kzUpUcc9Smz/hF3hhGn7TYsQZtcMRB1/W8JCq4qvUle0MIAOXn4XukmQXae6M25aHfHUad7H4BGrFP+P0398drrRQGaqSzos4Hpl1Ev6+3t4mlC81aeRi12asZoIuYTs+RxH4wSe/tbfX+579MuSm07iQGSSmbo8r9xcEREPjEJZ+hNl1YlZB3x/fpzMfVW9L0omiF+rPcTiiILmQh32LRkJ8ZDiAw4HXTZHYKR1sGe+NBqCjT5Y1ZGzQE+4RntZQ7L39qDN8N6VK1gSnL1pEsHui+kLBh8OF+VZWWw2625evbyZ4TPTOccgGf6oOnqNKNn1R6yu7ixK/eOVl1hWUbFZBaWcBvIw1IT4kjA63Q9C9cTqs2abxMzVdP4vdN1M5PC7vNoD9CYBS6GXqw+8B5ODgs6o7ktSNkeUas8XWFxb8xq+m/X112ujTuIcv7loDijzdmKwZq1YLhVEfPTtgGDxZLlGAwIbCvxK2WPjT7h9o+aDZpX3mf7Qwm6+mBMboz4i8b0QnSCIiSBsVx/tHbwA/bH8VwaIqqhZssQMhFCu3RoNlEQ5O9oV4qONmPDhuRNpt/ArBiCyCQ+N4LFvwVQr7t5y7ioW7JVCCoBSjBlQ3b2EEbH/4tbgt9VkCIXN5qSjcu/rmgMQvpYa4Mud6Hq4Z3GNe7jTgc084nD4E2pR5yqxkjgI7axtkHbDLcNKa2yNW7OwIrg0j+iK6AW6TMd/M3oXOH26FAUfdLi72tl6jcS3osbQZ+/8jed+frQdx2GGxT/NLvfLq002T+rgq4InFPcrzXHItfk6pNfAOnYPyB8zw8INSSpa5TOk11CKQ0eOv4SLDbuf8ffRNfOA4cl/dm4M6bANzx9R1RWdRt1bf05NPtzPN7lH32CtCzajFA1wmI6JpcXerG0QosKWjfltSdYTOE2+8SNW2xP3pTuQUpi7X5ewXeJZ06rtXcS09URjZCRd1Vob6zDavL1chuJOmK0eoZpCS0IlaLzIZsiaQXpQeAVVMA8+oUX/JHovkYD+iWy+Q12TtuIR6sOFR6upajX677lFRY+P8NjoharzWKBhZwkxII31I9aU/T59G5/qmWvi+HUiz7Ke/cbIceQeHejjbcyh4coq9K15SvthpAIkO9WohOXsJBIEGBUNA6bzree4nHOkPD9A34FXjjs6dFnbc9S9Edu8PlfKJ5E6I1z680J3UkoA749fHfK8wV5lIVqd81LLYQweT7U5ue2AQmAECxcXW0cBDsMv3mQL9wBcdKdxFOb9ZEKkako1REjm5RuIlJ9tcc9X5ZSE1vCh/enj3Lj1UrY2qNeTYCsSoSEoEmIyarDzG9q4Nd5K5fTW8IHKgDbwA3Al4Nu8fQLlc9OnHp8zUXXr6tfVrn/5LfqdjZANCJuIamnlg/kVBpp6364KbdapnI1B1FoEhKUZbqSx9IxgDpj1bY+0R9Apms4vV7bLFQQks8HsBFxZ80dlVxOt//YqOXy6x4oCmg8x5Gxb6z6oHybcO34LUIUHOHuC+Dcc0B+lAQjINHEn395KNcgZiO57Line truncated
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-interactions",
"provider:google",
"protocol:google-interactions"
],
"name": "google-interactions/streams-text-and-reports-usage",
"recordedAt": "2026-10-03T18:08:32.334Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/interactions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"input\":[{\"type\":\"user_input\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly one word: hello\"}]}],\"stream\":true,\"store\":false,\"generation_config\":{\"max_output_tokens\":2048,\"thinking_level\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: interaction.created\ndata: {\"interaction\":{\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.created\"}\n\nevent: interaction.status_update\ndata: {\"status\":\"in_progress\",\"event_type\":\"interaction.status_update\"}\n\nevent: step.start\ndata: {\"index\":0,\"step\":{\"type\":\"thought\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":0,\"delta\":{\"signature\":\"EmkKZwFpFH0TKZDuA7i8rhztLxjVI9+u+bpJI/x6/j60nie/dFgj5TyzAJCDDZQYfmeXEBrujJcdKT5FiKPuuMYWq1shTTILJ1JQpG2WkGAk6rAxxgxp7Ac0+rporJ7knHFF/jVWQD+AYGY=\",\"type\":\"thought_signature\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":0,\"event_type\":\"step.stop\"}\n\nevent: step.start\ndata: {\"index\":1,\"step\":{\"type\":\"model_output\"},\"event_type\":\"step.start\"}\n\nevent: step.delta\ndata: {\"index\":1,\"delta\":{\"text\":\"hello\",\"type\":\"text\"},\"event_type\":\"step.delta\"}\n\nevent: step.stop\ndata: {\"index\":1,\"event_type\":\"step.stop\"}\n\nevent: interaction.completed\ndata: {\"interaction\":{\"status\":\"completed\",\"usage\":{\"total_tokens\":9,\"total_input_tokens\":8,\"input_tokens_by_modality\":[{\"modality\":\"text\",\"tokens\":8}],\"total_cached_tokens\":0,\"total_output_tokens\":1,\"total_tool_use_tokens\":0,\"total_thought_tokens\":0,\"raw_prompt_token\":39,\"model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":39}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":5}]}],\"non_grounding_model_invocation_token_counts\":[{\"prompt_tokens_details\":[{\"modality\":\"text\",\"tokens\":39}],\"candidates_tokens_details\":[{\"modality\":\"text\",\"tokens\":5}]}]},\"created\":\"2026-10-03T18:08:32Z\",\"updated\":\"2026-10-03T18:08:32Z\",\"service_tier\":\"standard\",\"object\":\"interaction\",\"model\":\"gemini-3.8-flash\"},\"event_type\":\"interaction.completed\"}\n\nevent: done\ndata: [DONE]\n\n"
}
}
]
}
@@ -2,9 +2,16 @@
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
"tags": [
"prefix:mistral-chat",
"provider:mistral",
"protocol:mistral-chat",
"tool",
"tool-loop",
"usage"
],
"name": "mistral-chat/drives-a-tool-loop",
"recordedAt": "2026-08-30T17:18:49.552Z"
"recordedAt": "2026-10-03T04:09:49.878Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":120,\"total_tokens\":132,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghij\"}\n\ndata: [DONE]\n\n"
}
},
{
@@ -33,14 +40,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"EwgHkPRLW\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with a\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature of 1\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"8°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"total_tokens\":84,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: [DONE]\n\n"
}
}
]
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-chat/continues-after-tool-result",
"recordedAt": "2026-05-06T01:33:31.878Z",
"tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Answer using only the provided tool result.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_weather\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_weather\",\"content\":\"{\\\"forecast\\\":\\\"sunny\\\",\\\"temperature_c\\\":22}\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gJ6VDZ2ZE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"B2pU6Neg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sa2\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ENFjAfta\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"E1Kbi\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"NWj8HasA\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"irmMg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3eCMq6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"XKMqPUsnt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BFVrBA09z9Y3lAC\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AwG4puOX\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"pKQU39KXN6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xeTNA1JuE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.compLine truncated
}
}
]
}
@@ -1,46 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-chat/drives-a-tool-loop-end-to-end",
"recordedAt": "2026-05-06T01:33:29.747Z",
"tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool", "tool-loop"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ayQl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TWZNUL5mYYtjWu\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QidSCtgZRvDHL\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"nupQO1L4GdWo\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3W5B3hzGrFvl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"JgscYuZR4Lmp5S\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"BtZF5TaQjX3UwLN\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":64,\"completion_tokens\":14,\"total_tokens\":78,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"bZ51l7ptxM\"}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"SCCu2B8Ri\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"vuE4h8te\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uzt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4vVdGuJc\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hAfFt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uuNXNXne\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"HRMlI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Ii1R2u\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ay3ddthfT\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"PtxyVsfiluBGiWj\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"WuI4V7O6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Z5wHwpykrS\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Fi66TTzMb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.compLine truncated
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-chat/streams-text",
"recordedAt": "2026-05-06T01:33:30.542Z",
"tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"g9SWm2h6J\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lVzwlh\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"onzhziaLGv\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"LzUj1\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"emMuPcvvOkI\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-chat/streams-tool-call",
"recordedAt": "2026-05-06T01:33:31.127Z",
"tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly 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}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_5wBV98AvGPwOyC6a2HtKh85w\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hrw8\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"MzOlaTohF20Sbb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QuYBQ5vYEUVxR\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"spyXlsV2hl6l\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Db1cjFKa6YAI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"oPu35nrhXcjTL5\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"63TVy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"NxJjur40z4H\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/deepseek-streams-text",
"recordedAt": "2026-04-28T21:18:49.498Z",
"tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:deepseek"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.deepseek.com/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,53 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop",
"recordedAt": "2026-05-06T01:35:06.032Z",
"tags": [
"prefix:openai-compatible-chat",
"protocol:openai-compatible-chat",
"provider:groq",
"tool",
"tool-loop",
"golden"
]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.groq.com/openai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"seed\":1587279809}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}},\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.groq.com/openai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"4vgxtgdfg\",\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"seed\":524268521}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logLine truncated
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/groq-streams-text",
"recordedAt": "2026-05-06T01:35:05.532Z",
"tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.groq.com/openai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"seed\":687314058}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}},\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[],\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/groq-streams-tool-call",
"recordedAt": "2026-05-06T01:35:05.706Z",
"tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.groq.com/openai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly 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}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"seed\":1846647562}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"mcf2d8nn1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}},\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[],\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,54 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop",
"recordedAt": "2026-05-06T01:35:14.282Z",
"tags": [
"prefix:openai-compatible-chat",
"protocol:openai-compatible-chat",
"provider:openrouter",
"tool",
"tool-loop",
"golden",
"flagship"
]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"P\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ari\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"s\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}],\"usage\":{\"prompt_tokens\":802,\"completion_tokens\":66,\"total_tokens\":868,\"cost\":0.00566,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00566,\"upstream_inference_prompt_cost\":0.00401,\"upstream_inference_completions_cost\":0.00165},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"It\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'s sunny and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" 22°C in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris.\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":899,\"completion_tokens\":19,\"total_tokens\":918,\"cost\":0.00497,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00497,\"upstream_inference_prompt_cost\":0.004495,\"upstream_inference_completions_cost\":0.000475},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,53 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop",
"recordedAt": "2026-05-06T01:35:08.922Z",
"tags": [
"prefix:openai-compatible-chat",
"protocol:openai-compatible-chat",
"provider:openrouter",
"tool",
"tool-loop",
"golden"
]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":66,\"completion_tokens\":14,\"total_tokens\":80,\"cost\":0.0000183,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000183,\"upstream_inference_prompt_cost\":0.0000099,\"upstream_inference_completions_cost\":0.0000084},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"idLine truncated
}
}
]
}
@@ -1,54 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop",
"recordedAt": "2026-05-06T01:35:11.662Z",
"tags": [
"prefix:openai-compatible-chat",
"protocol:openai-compatible-chat",
"provider:openrouter",
"tool",
"tool-loop",
"golden",
"flagship"
]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":69,\"completion_tokens\":18,\"total_tokens\":87,\"cost\":0.000885,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.000885,\"upstream_inference_prompt_cost\":0.000345,\"upstream_inference_completions_cost\":0.00054},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"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\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":108,\"completion_tokens\":12,\"total_tokens\":120,\"cost\":0.0009,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0009,\"upstream_inference_prompt_cost\":0.00054,\"upstream_inference_completions_cost\":0.00036},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/openrouter-streams-text",
"recordedAt": "2026-05-06T01:35:06.767Z",
"tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":3,\"total_tokens\":24,\"cost\":0.00000495,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00000495,\"upstream_inference_prompt_cost\":0.00000315,\"upstream_inference_completions_cost\":0.0000018},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 1,
"metadata": {
"name": "openai-compatible-chat/openrouter-streams-tool-call",
"recordedAt": "2026-05-06T01:35:07.466Z",
"tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly 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}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_L7mHMq49ZSUTBHjLJfBIP2eT\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"cost\":0.00001305,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00001305,\"upstream_inference_prompt_cost\":0.00001005,\"upstream_inference_completions_cost\":0.000003},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
-1
View File
@@ -8,7 +8,6 @@ import {
type ProviderMetadata,
type ToolCallPart,
ToolResultPart,
type ToolResultValue,
type Usage,
} from "../../src/schema/index.js"
import { type Tools, toDefinitions } from "../../src/tool.js"
+1 -3
View File
@@ -39,9 +39,7 @@ describe("provider error classification", () => {
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
failures.map(() => expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" })),
)
})
+11
View File
@@ -430,6 +430,7 @@ describe("provider package entrypoints", () => {
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode/ai/providers/google")
const GoogleInteractions = await import("@opencode/ai/providers/google/interactions")
const selected = Google.model("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
@@ -443,6 +444,16 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
const interactions = GoogleInteractions.model("gemini-3.8-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
thinkingLevel: "low",
store: true,
})
expect(interactions.route.id).toBe("google-interactions")
expect(interactions.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(interactions.route.defaults.providerOptions).toEqual({ thinkingLevel: "low", store: true })
expect(Google.configure().interactions("gemini-3.8-flash").route.protocol).toBe("google-interactions")
})
test("selects Vertex entrypoints with the same model contract", async () => {
@@ -21,7 +21,6 @@ import {
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, SystemPart } from "../../src/index.js"
import { Cohere } from "../../src/providers/cohere.js"
import { LLMClient } from "../../src/route.js"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
@@ -55,12 +55,20 @@ recorded.effect(
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
LLMRequest.update(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
{
system: [
SystemPart.make("Use the get_weather tool exactly once."),
SystemPart.make("After the tool result, reply exactly: Paris is sunny."),
],
},
),
)
expectWeatherToolLoop(events)
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
+9 -3
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMClient, LLMEvent, Media, Message, isRetryable } from "../../src/index.js"
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, isRetryable } from "../../src/index.js"
import { Cohere } from "../../src/providers/cohere.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -23,7 +23,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
const prepared = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
system: "Be concise.",
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.user("Lookup Paris"),
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
@@ -44,7 +44,13 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
thinking: { type: "enabled", token_budget: 128 },
tool_choice: "REQUIRED",
messages: [
{ role: "system", content: "Be concise." },
{
role: "system",
content: [
{ type: "text", text: "Be concise.\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
{
role: "assistant",
@@ -52,7 +52,6 @@ describe("experimental Evaluation recorded", () => {
typesafe.effect("evaluates choice score and boolean questions", () =>
assertEvaluation(
TypeSafeAI.configure({ apiKey: process.env.TYPESAFE_API_KEY ?? "fixture" }).experimental.evaluation("jev-latest"),
"typesafe",
),
)
@@ -61,7 +60,6 @@ describe("experimental Evaluation recorded", () => {
OpenCodeZen.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
"jev-1.13-free",
),
"opencode",
),
)
@@ -70,15 +68,11 @@ describe("experimental Evaluation recorded", () => {
OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }).experimental.evaluation(
"typesafe/jev-1.13",
),
"openrouter",
),
)
})
const assertEvaluation = <Options extends EvaluationOptions>(
model: EvaluationModel<Options>,
metadataKey: "typesafe" | "opencode" | "openrouter",
) =>
const assertEvaluation = <Options extends EvaluationOptions>(model: EvaluationModel<Options>) =>
Effect.gen(function* () {
const response = yield* Evaluation.run({ model, state, questions })
expect(response.model).toContain("jev-")
@@ -0,0 +1,169 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { Google } from "../../src/providers.js"
import { LLMClient, RequestExecutor } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
import { weatherTool } from "../recorded-scenarios.js"
const model = Google.configure({ apiKey: process.env.GEMINI_API_KEY ?? "fixture" }).interactions("gemini-3.8-flash")
const recorded = recordedTests({
prefix: "google-interactions",
provider: "google",
protocol: "google-interactions",
requires: ["GEMINI_API_KEY"],
})
const InteractionMetadata = Schema.Struct({ interactionId: Schema.String })
describe("Google Interactions recorded", () => {
recorded.effect("streams text and reports usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "Reply with exactly one word: hello",
generation: { maxTokens: 2048 },
providerOptions: { thinkingLevel: "low" },
}),
)
expect(response.text.trim().toLowerCase()).toBe("hello")
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.contextTokens).toBeGreaterThan(0)
expect(response.usage?.providerMetadata?.google).toMatchObject({ total_input_tokens: expect.any(Number) })
}),
)
recorded.effect(
"streams reasoning and retains thought signatures",
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt:
"Find the smallest positive integer that leaves remainder 1 modulo 7, 2 modulo 9, and 3 modulo 11. Explain briefly.",
generation: { maxTokens: 4096 },
providerOptions: { thinkingLevel: "high", thinkingSummaries: "auto" },
}),
)
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual(
expect.arrayContaining([
expect.objectContaining({
providerMetadata: { google: { interactionSignature: expect.any(String) } },
}),
]),
)
expect(response.text.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
}),
120_000,
)
recorded.effect(
"replays native tool results and signatures statelessly",
() =>
Effect.gen(function* () {
const request = LLM.request({
model,
system: "Use get_weather for weather questions. Answer concisely after receiving the result.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
toolChoice: { type: "tool", name: weatherTool.name },
generation: { maxTokens: 2048 },
providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto" },
})
const first = yield* LLMClient.generate(request)
expect(first.toolCalls).toHaveLength(1)
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
expect(first.finishReason.normalized).toBe("tool-calls")
const call = first.toolCalls[0]
if (!call) throw new Error("Missing recorded weather tool call")
expect(call.name).toBe(weatherTool.name)
expect(call.input).toEqual({ city: "Paris" })
expect(call.providerMetadata?.google).toHaveProperty("interactionSignature")
const second = yield* LLMClient.generate(
LLM.request({
model,
system: request.system,
tools: [weatherTool],
toolChoice: "none",
generation: { maxTokens: 2048 },
providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto" },
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: { temperature: 22, condition: "sunny" } }),
],
}),
)
expect(second.text.toLowerCase()).toContain("sunny")
expect(second.text).toContain("22")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
}),
120_000,
)
recorded.effect(
"continues tool results with previous interaction id",
() =>
Effect.gen(function* () {
const request = LLM.request({
model,
system: "Use get_weather for weather questions. Answer concisely after receiving the result.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
toolChoice: "required",
generation: { maxTokens: 2048 },
providerOptions: { thinkingLevel: "low", store: true },
})
const first = yield* LLMClient.generate(request)
const metadata = yield* Schema.decodeUnknownEffect(InteractionMetadata)(
first.events.find(LLMEvent.is.finish)?.providerMetadata?.google,
)
const executor = yield* RequestExecutor.Service
const cleanup = executor
.execute(
HttpClientRequest.delete(
`https://generativelanguage.googleapis.com/v1beta/interactions/${metadata.interactionId}`,
).pipe(HttpClientRequest.setHeader("x-goog-api-key", process.env.GEMINI_API_KEY ?? "fixture")),
)
.pipe(Effect.orDie)
yield* Effect.gen(function* () {
expect(first.toolCalls).toHaveLength(1)
const call = first.toolCalls[0]
if (!call) throw new Error("Missing recorded weather tool call")
const second = yield* LLMClient.generate(
LLM.request({
model,
system: request.system,
tools: [weatherTool],
toolChoice: "none",
generation: { maxTokens: 2048 },
providerOptions: { thinkingLevel: "low", previousInteractionId: metadata.interactionId, store: false },
messages: [
Message.tool({ id: call.id, name: call.name, result: { temperature: 22, condition: "sunny" } }),
],
}),
)
expect(second.text.toLowerCase()).toContain("sunny")
expect(second.text).toContain("22")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
}).pipe(Effect.ensuring(cleanup))
}),
120_000,
)
})
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/index.js"
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
@@ -41,7 +41,7 @@ describe("Mistral Chat", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial",
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.system("Updated"),
Message.user([
@@ -105,7 +105,13 @@ describe("Mistral Chat", () => {
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{ role: "system", content: "Initial" },
{
role: "system",
content: [
{ type: "text", text: "Initial\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
{
role: "user",
@@ -1,7 +1,7 @@
import { configure } from "@opencode/ai/providers/mistral"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
@@ -97,7 +97,10 @@ describe("Mistral recorded", () => {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: "Call lookup_weather exactly once with Paris.",
system: [
SystemPart.make("Call lookup_weather exactly once with Paris."),
SystemPart.make("After the tool result, describe the weather briefly."),
],
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse, LanguageModel } from "../../src/index.js"
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
import * as OpenRouter from "../../src/providers/openrouter.js"
import { LLMClient } from "../../src/route.js"
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
@@ -3895,7 +3895,6 @@ describe("OpenAI Responses route", () => {
it.effect("preserves foreign hosted images as portable image content when storage is enabled", () =>
Effect.gen(function* () {
const item = { type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" }
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
-5
View File
@@ -127,11 +127,6 @@ const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
export const expectFinish = (events: ReadonlyArray<LLMEvent>, reason: FinishReason) =>
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
export const expectWeatherToolCall = (response: LLMResponse) =>
expect(response.toolCalls).toMatchObject([
{ type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } },
])
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
const finishes = events.filter(LLMEvent.is.finish)
expect(finishes).toHaveLength(1)
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
@@ -1,7 +1,7 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
import { expect, sourceURL, story } from "../../storybook/playwright/story"
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
const modules = {
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
host: source("../src/runtime/extension/host.tsx"),
@@ -13,49 +13,74 @@ const modules = {
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
}
story.beforeEach(async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
import(modules.fixture),
import(modules.host),
import(modules.panels),
import(modules.language),
import(modules.browser),
import(modules.file),
])
mountBrowserRegion({
LanguageProvider: language.LanguageProvider,
ExtensionHostProvider: host.ExtensionHostProvider,
useExtensionHost: host.useExtensionHost,
createRegion: panels.createRegion,
definitions: [
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
],
})
}, modules)
})
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const tabs = root.getByRole("tab")
const tree = root.getByTestId("tree")
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
await expect(tabs).toHaveText(["alpha.ts"])
await expect(tree).toHaveText('{"tab":"changes"}')
// Beta was left on its browser tab, which the desktop has not reported yet.
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
await expect(tabs).toHaveText(["beta.ts"])
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
await expect(tree).toHaveText('{"tab":"changes"}')
await root.getByRole("button", { name: "First inventory", exact: true }).click()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(tree).toHaveText('{"tab":"changes"}')
})
story(
"keeps a restored browser tab selected and undrawn until the desktop's first inventory",
async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
import(modules.fixture),
import(modules.host),
import(modules.panels),
import(modules.language),
import(modules.browser),
import(modules.file),
])
mountBrowserRegion({
LanguageProvider: language.LanguageProvider,
ExtensionHostProvider: host.ExtensionHostProvider,
useExtensionHost: host.useExtensionHost,
createRegion: panels.createRegion,
definitions: [
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
],
})
}, modules)
"keeps the browser tabs while the pane's Ipc is away and registers them again when it returns",
async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const tabs = root.getByRole("tab")
const tree = root.getByTestId("tree")
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
await expect(tabs).toHaveText(["alpha.ts"])
await expect(tree).toHaveText('{"tab":"changes"}')
// Beta was left on its browser tab, which the desktop has not reported yet.
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
await expect(tabs).toHaveText(["beta.ts"])
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
await expect(tree).toHaveText('{"tab":"changes"}')
await root.getByRole("button", { name: "First inventory", exact: true }).click()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
// The pane's main extension reloads: every binding goes with it, and the strip keeps the tab it will restore.
await root.getByRole("button", { name: "Pane away", exact: true }).click()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(tree).toHaveText('{"tab":"changes"}')
// Both attachments register again at once, without a retry timer; Beta hands main the tab to restore.
await root.getByRole("button", { name: "Pane back", exact: true }).click()
await expect(root.getByText("Registrations: 4", { exact: true })).toBeVisible()
await expect(root.getByText("Beta restores: 1", { exact: true })).toBeVisible()
await expect(tabs).toHaveText(["beta.ts", "Preview"])
},
)
@@ -1,24 +1,25 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
import { expect, sourceURL, story } from "../../storybook/playwright/story"
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
const modules = {
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
surface: source("../src/runtime/extension/surface.tsx"),
embeds: source("../src/runtime/extension/embeds.tsx"),
language: source("../src/runtime/i18n/language.tsx"),
}
story.beforeEach(async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the pane beside it on the real host surface.
// Any story loads the app styles; the fixture mounts the pane beside it on the real host embeds.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserPane }, { createSurfaces }, language] = await Promise.all([
const [{ mountBrowserPane }, { createEmbeds }, language] = await Promise.all([
import(modules.fixture),
import(modules.surface),
import(modules.embeds),
import(modules.language),
])
mountBrowserPane({
createSurfaces,
createEmbeds,
LanguageProvider: language.LanguageProvider,
UiI18nBridge: language.UiI18nBridge,
useLanguage: language.useLanguage,
@@ -26,6 +27,7 @@ story.beforeEach(async ({ mount, page }) => {
}, modules)
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides a native page when another takes the pane, the pane hides, or it unmounts", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const alpha = root.getByTestId("native-Alpha")
@@ -50,11 +52,14 @@ story("hides the native view immediately while the pane stays mounted", async ({
const root = page.getByTestId("browser-pane-fixture")
const toggle = root.getByRole("button", { name: "Toggle Review tab", exact: true })
await expect(toggle).toBeEnabled()
// Read in the same task as the click so a deferred animation-frame hide cannot pass.
const visible = await toggle.evaluate((element) => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }))
return document.querySelector('[data-testid="native-Alpha"]')?.getAttribute("data-visible")
})
expect(visible).toBe("false")
await expect(root.locator("#browser-panel")).toHaveCount(1)
await toggle.click()
@@ -87,6 +92,7 @@ story("comments on a picked element over a still of the page", async ({ page })
await picker.click()
await expect(picker).toHaveAttribute("aria-pressed", "true")
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible()
await expect(root.getByRole("status")).toHaveText(
"Click an element in the page to comment on it. Press Escape to cancel.",
)
@@ -95,6 +101,7 @@ story("comments on a picked element over a still of the page", async ({ page })
await expect(picker).toHaveAttribute("aria-pressed", "false")
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await expect(editor).toBeFocused()
await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible()
await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary")
// The spotlight frames the picked element in surface pixels.
await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "48px")
@@ -108,6 +115,19 @@ story("comments on a picked element over a still of the page", async ({ page })
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
// The pane stays mounted when Beta is routed, and its picker then listens to Beta's page.
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await picker.click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
await expect(editor).toBeFocused()
await editor.fill("Beta's button too")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments").getByRole("listitem")).toHaveText([
"button.primary @e7: Make this the primary colour",
"button.primary @e7: Beta's button too",
])
})
story("cancels the picker and a comment with Escape", async ({ page }) => {
@@ -129,6 +149,44 @@ story("cancels the picker and a comment with Escape", async ({ page }) => {
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("cleans up the originating session's picker and comment after a session switch", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
await picker.click()
await expect(root.getByText("Picker Alpha: on", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Picker Alpha: off", { exact: true })).toBeVisible()
await expect(picker).toHaveAttribute("aria-pressed", "false")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await picker.click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
await expect(root.locator('[data-slot="browser-comment-editor"] textarea')).toBeFocused()
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
await expect(root.getByText("Highlight owners: Alpha", { exact: true })).toBeVisible()
})
story("ends the native picker and highlight when the pane unmounts", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
await picker.click()
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.locator("#browser-panel")).toHaveCount(0)
await expect(root.getByText("Picker: off", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await picker.click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
await expect(root.locator('[data-slot="browser-comment-editor"] textarea')).toBeFocused()
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.locator("#browser-panel")).toHaveCount(0)
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
})
story("keeps a comment draft but drops its ref when the page navigates", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
@@ -157,6 +215,7 @@ story("keeps the comment editor and its actions inside the page", async ({ page
.poll(async () => {
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
const box = await editor.boundingBox()
return !!surface && !!box && box.y + box.height <= surface.y + surface.height
})
.toBe(true)
@@ -212,17 +271,22 @@ story("keeps the submitted URL visible until the browser reports navigation", as
await expect(address).toHaveValue("https://example.com/")
})
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
story("restores the current URL each time the same submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
for (const submission of [1, 2]) {
await story.step(`blocked submission ${submission}`, async () => {
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByRole("alert")).toHaveText("Request failed")
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
}
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
@@ -280,10 +344,12 @@ story("selects the full URL when the address field gains focus", async ({ page }
await address.focus()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
// A click on the focused field places the caret instead of selecting the URL again.
await address.press("ArrowRight")
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
await expect
.poll(() => address.evaluate((input: HTMLInputElement) => input.selectionStart === input.selectionEnd))
.toBe(true)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
@@ -0,0 +1,83 @@
import type { Definition } from "@opencode/gui-extensions/sdk"
import { expect, sourceURL, story } from "../../storybook/playwright/story"
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
const modules = {
fixture: source("./extension-host.fixture.tsx"),
builtins: source("../../gui-extensions/src/renderer.ts"),
}
story.beforeEach(async ({ mount }) => {
// Any story loads the app; the fixture mounts the real host beside it.
await mount("ui-line-comment--editor")
})
// The built-in renderer composition as a graph: `requires` edges must not form a cycle, and every optional (`uses`)
// edge gets one row that boots the real host with that provider disabled. The first row disables nothing.
story("built-ins: no requires cycle, and each consumer activates without each optional provider", async ({ page }) => {
const result = await page.evaluate(async (modules) => {
const [{ mountExtensions, until }, { builtins }] = await Promise.all([
import(modules.fixture),
import(modules.builtins),
])
// The window this host serves has no OS, so OS-specific built-ins are left out as the app leaves them out.
const composition: readonly Definition[] = builtins
const definitions = composition.filter((definition) => !definition.os)
const ids = (tokens: Definition["provides"]) => Object.values(tokens ?? {}).map((token) => token.id)
const providerOf = (token: string) => definitions.find((definition) => ids(definition.provides).includes(token))?.id
const requires = new Map(
definitions.map((definition) => [
definition.id,
ids(definition.requires).flatMap((token) => providerOf(token) ?? []),
]),
)
const cycles = definitions.flatMap((definition) => {
const walk = (id: string, path: readonly string[]): string[][] =>
path.includes(id)
? id === definition.id
? [[...path, id]]
: []
: (requires.get(id) ?? []).flatMap((next) => walk(next, [...path, id]))
return walk(definition.id, [])
})
// A renderer that uses the Ipc its own main entry provides depends on no other extension.
const edges = definitions.flatMap((consumer) =>
ids(consumer.uses).flatMap((token) => {
const provider = providerOf(token)
return provider && provider !== consumer.id ? [{ consumer: consumer.id, provider, token }] : []
}),
)
const rows = [{ consumer: "*", provider: "", token: "" }, ...edges]
const outcomes = []
for (const row of rows) {
const host = mountExtensions({ definitions, disabled: row.provider ? [row.provider] : [] })
host.release()
await until(() => host.ready())
const consumers = row.consumer === "*" ? definitions.map((definition) => definition.id) : [row.consumer]
outcomes.push({
...row,
results: consumers.map((id) => ({ id, status: host.status(id), failure: host.failure(id)?.error ?? null })),
})
host.unmount()
}
return { cycles, outcomes }
}, modules)
expect(result.cycles).toEqual([])
expect(result.outcomes).toEqual(
result.outcomes.map((outcome) => ({
...outcome,
results: outcome.results.map((item) => ({ id: item.id, status: "active", failure: null })),
})),
)
})
@@ -1,21 +1,150 @@
import { DialogProvider } from "@opencode/ui/context/dialog"
import type { Setup } from "@opencode/gui-extensions/sdk"
import { createSignal } from "solid-js"
import {
Panel,
type Appearance,
type Build,
type Definition,
type Keybinds,
type Layout,
type Locale,
type MountedSession,
type Router,
type Servers,
type SessionScreen,
type Setup,
type Storage,
type StoreOptions,
type Workspaces,
} from "@opencode/gui-extensions/sdk"
import {
createComponent,
createMemo,
createSignal,
getOwner,
onCleanup,
runWithOwner,
Show,
type ParentProps,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Schema } from "effect"
import { render } from "solid-js/web"
import { ExtensionHostProvider, useExtensionHost } from "../src/runtime/extension/host"
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerScope } from "@/runtime/server/scope"
import { ExtensionHostProvider, useExtensionHost, type HostApiFactories } from "../src/runtime/extension/host"
import { createHostApis } from "../src/runtime/extension/host-apis"
import { createInstalled } from "../src/runtime/extension/installed"
import { extensionEnabled } from "@opencode/gui-extensions/sdk/bridge"
import { createRegion, RegionContent } from "../src/runtime/extension/panels"
import { ExtensionSlot } from "../src/runtime/extension/render"
import { persistedHandle } from "../src/runtime/extension/stores"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { GuiExtensionsSettings } from "../src/runtime/extension/settings-page-dev"
export { bindExtension, useExtension, Contract, createKeyed, Panel, Slot, Store } from "@opencode/gui-extensions/sdk"
export { Schema }
type ExtensionHost = ReturnType<typeof useExtensionHost>
/** What the app interface hands the real host APIs when it mounts. */
type Interface = Parameters<ReturnType<typeof createHostApis>["attach"]>[0]
/** The host APIs a mounted fixture created, once it rendered. */
type Captured = { apis?: ReturnType<typeof createHostApis> }
/** A value the fixture's storage holds as JSON. */
type Json = string | number | boolean | null | readonly Json[] | { readonly [key: string]: Json }
export { createComponent, createMemo, createSignal, getOwner, onCleanup, runWithOwner }
const layout: Layout = {
narrow: () => false,
ready: () => true,
open() {},
close() {},
toggle() {},
state: () => "closed",
stored: () => [],
side: { opened: () => false, toggle() {} },
sidebar: { opened: () => true },
dock: { opened: () => false, placement: () => "bottom" },
scroll: { get: () => undefined, set() {} },
settings() {},
project() {},
}
const build: Build = { version: "", channel: "dev", platform: "desktop", packaged: false }
const locale: Locale = { locale: () => "en", direction: () => "ltr", setDirection() {} }
const appearance: Appearance = { font: () => "monospace" }
const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
const servers: Servers = { list: () => [] }
const workspaces: Workspaces = { on: () => () => undefined }
/** Every HostApi faked at its boundary, with the given storage. */
function fakeApis(storage: (extension: string) => Storage): HostApiFactories {
return {
build: () => build,
locale: () => locale,
appearance: () => appearance,
router: () => router,
keybinds: () => keybinds,
servers: () => servers,
workspaces: () => workspaces,
desktop: () => undefined,
sessions: () => ({ list: () => [], current: () => undefined }),
screen: () => ({ current: () => undefined }),
layout: () => layout,
storage,
system: () => ({ copy: async () => {}, save: async () => false, openExternal() {} }),
embeds: () => ({ View: () => null, capture: async () => undefined }),
}
}
/** The fake HostApis' app interface, which is always mounted: a dialog shows at once. */
function mounted(run: () => void) {
run()
return () => undefined
}
/** Storage that no test of `mountExtensionHost` reads. */
const unused = (): Storage => {
throw new Error("The fixture extension reads no storage")
}
/** Resolves once `check` holds, checking every frame; rejects after five seconds. */
export async function until(check: () => boolean) {
const deadline = performance.now() + 5000
while (!check()) {
if (performance.now() > deadline) throw new Error("The fixture never reached the expected state")
await new Promise((resolve) => requestAnimationFrame(resolve))
}
}
/** Mounts the real extension host with one extension whose every renderer load settles when the test says so. */
export function mountExtensionHost() {
const loads: PromiseWithResolvers<{ default: Setup }>[] = []
const loads: PromiseWithResolvers<{ default: Setup<Definition> }>[] = []
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set())
const state = { host: undefined as ReturnType<typeof useExtensionHost> | undefined }
const hosts: ExtensionHost[] = []
const container = document.createElement("div")
document.body.appendChild(container)
function Capture() {
state.host = useExtensionHost()
hosts.push(useExtensionHost())
return null
}
const dispose = render(
() => (
<LanguageProvider locale="en">
@@ -25,14 +154,16 @@ export function mountExtensionHost() {
{
id: "fixture",
renderer: () => {
const load = Promise.withResolvers<{ default: Setup }>()
const load = Promise.withResolvers<{ default: Setup<Definition> }>()
loads.push(load)
return load.promise
},
},
]}
disabled={disabled}
services={[]}
apis={fakeApis(unused)}
whenMounted={mounted}
>
<Capture />
</ExtensionHostProvider>
@@ -41,22 +172,382 @@ export function mountExtensionHost() {
),
container,
)
return {
unmount: () => {
dispose()
container.remove()
},
/** Resolves the nth renderer load (the first by default) with this setup. */
load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }),
load: (setup: Setup<Definition>, index = 0) => loads[index].resolve({ default: setup }),
/** Rejects the nth renderer load. */
fail: (index: number, error: unknown) => loads[index].reject(error),
fail: (index: number, cause: unknown) => loads[index].reject(cause),
/** Renderer loads requested so far. */
count: () => loads.length,
reload: () => state.host?.reload("fixture"),
reload: () => hosts[0]?.reload("fixture"),
disable: () => setDisabled(new Set(["fixture"])),
enable: () => setDisabled(new Set<string>()),
status: () => state.host?.state.status.fixture,
status: () => hosts[0]?.state.status.fixture,
/** Contributions the host holds for a point; readable after the host unmounts. */
entries: (point: string) => state.host?.state.entries[point]?.length ?? 0,
entries: (point: string) => hosts[0]?.state.entries[point]?.length ?? 0,
}
}
/**
* Mounts the real host over these definitions, before any session mounts, with the HostApis faked at their
* boundary. Storage is the real persisted store of a desktop window whose reads wait until `release()`; `stored` seeds
* it. Renders the `window.bottom` slot once the startup gate opens.
*/
export function mountExtensions(input: {
definitions: readonly Definition[]
disabled?: readonly string[]
bridge?: Parameters<typeof createInstalled>[0]
stored?: Readonly<Record<string, Json>>
/** Shows the production developer settings page for status-label contracts. */
settings?: boolean
}) {
const held = Promise.withResolvers<void>()
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set(input.disabled ?? []))
const hosts: ExtensionHost[] = []
const container = document.createElement("div")
document.body.appendChild(container)
const platform: Platform = {
platform: "desktop",
windowID: "extension-host-fixture",
openExternal: () => undefined,
openDirectoryPickerDialog: async () => null,
restart: async () => undefined,
notify: async () => undefined,
storage: () => ({
getItem: async (key: string) => {
await held.promise
return key in (input.stored ?? {}) ? JSON.stringify(input.stored?.[key]) : null
},
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}
const storage = (extension: string): Storage => ({
store<S extends Schema.ConstraintCodec<object, unknown>>(key: string, options: StoreOptions<S>) {
const pair = persisted(Persist.global(`extension.${extension}.${key}`), options.schema, options.initial, platform)
return persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise })
},
memory: (_key, options) => {
const [value, set] = createStore(options.initial)
return [value, (mutation) => set(produce(mutation))] as const
},
remove() {},
})
function MountedHost() {
const installed = input.bridge ? createInstalled(input.bridge) : undefined
const disabledState = createMemo(() => {
if (!installed) return disabled()
const state = installed.enableState()
if (!state) return undefined
return new Set(
input.definitions.flatMap((definition) => (extensionEnabled(definition, state) ? [] : [definition.id])),
)
})
return (
<ExtensionHostProvider
definitions={input.definitions}
disabled={disabledState}
apis={fakeApis(storage)}
whenMounted={mounted}
>
<Capture />
</ExtensionHostProvider>
)
}
function Capture() {
const host = useExtensionHost()
hosts.push(host)
return (
<Show when={host.ready()}>
<Show when={input.settings}>
<GuiExtensionsSettings />
</Show>
<ExtensionSlot at="window.bottom" input={{}} />
</Show>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<DialogProvider>
<MountedHost />
</DialogProvider>
</LanguageProvider>
),
container,
)
return {
container,
unmount: () => {
dispose()
container.remove()
},
/** Lets the held storage reads answer. */
release: () => held.resolve(),
disable: (ids: readonly string[]) => setDisabled(new Set(ids)),
reload: (id: string) => hosts[0]?.reload(id),
ready: () => hosts[0]?.ready() ?? false,
status: (id: string) => hosts[0]?.state.status[id],
failure: (id: string) => hosts[0]?.state.failures[id],
entries: (point: string) => hosts[0]?.state.entries[point]?.length ?? 0,
}
}
const web: Platform = {
platform: "web",
openExternal: () => undefined,
restart: async () => undefined,
notify: async () => undefined,
}
/** A stand-in for the app interface the real host APIs attach to; each test overrides what it observes. */
function standIn(overrides: Partial<Interface>): Interface {
return {
sessions: () => [],
current: () => undefined,
screen: () => undefined,
scope: () => ServerScope.local,
scoped: () => undefined,
layout: {
ready: () => true,
open() {},
close() {},
toggle() {},
state: () => "closed",
stored: () => [],
side: { opened: () => true, toggle() {} },
sidebar: { opened: () => false },
dock: { opened: () => false, placement: () => "bottom" },
scroll: { get: () => undefined, set() {} },
},
settings() {},
project() {},
font: () => "fixture mono",
routing: () => false,
path: () => "/",
keybind: () => [],
matches: () => false,
servers: () => ["local"],
...overrides,
}
}
/** The real host over the real HostApis of a web window; `mounted`, when given, attaches as the app interface. */
function RealHost(props: ParentProps<{ definitions: readonly Definition[]; captured: Captured; mounted?: Interface }>) {
const apis = createHostApis()
props.captured.apis = apis
if (props.mounted) onCleanup(apis.attach(props.mounted))
return (
<ExtensionHostProvider
definitions={props.definitions}
disabled={() => new Set<string>()}
apis={apis.apis}
whenMounted={apis.whenMounted}
>
{props.children}
</ExtensionHostProvider>
)
}
/**
* Mounts the real host and HostApis, and the parts of the session screen that render extension content for the routed
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, and the
* `session.panel.end` slot, whose input the screen builds this way. `route(id)` routes a session the way the screen
* does: a new session object each time, delivered through the same reactive inputs, while the one screen object
* `ctx.screen` returns follows the route. Its composer records which session each attachment reached.
*/
export function mountSessionRegion(input: { definitions: readonly Definition[]; active: string }) {
const container = document.createElement("div")
document.body.appendChild(container)
const hosts: ExtensionHost[] = []
const captured: Captured = {}
const attached: string[] = []
const object = (id: string) => {
const value = { key: `fixture\n${id}`, id, tab: id, pending: false, location: { directory: "/repo" }, visit: {} }
// SAFETY: the region and slot pass the object through; the test's extension reads only `key` and `id`.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
return value as unknown as MountedSession
}
const [view, setView] = createSignal(object("a"))
const [routed, setRouted] = createSignal(true)
const route = {
composer: { attach: () => void attached.push(view().id), update() {}, detach() {} },
}
// SAFETY: the test's extension reads only `composer.attach` of the screen.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const screen = route as unknown as SessionScreen
const sidebar = { opened: () => false, width: () => 0, transition: () => false, resize() {}, toggle() {} }
function Region() {
const host = useExtensionHost()
hosts.push(host)
// Every panel's `main` tab is stored, and `active` selected, as a strip restored after a reload.
const all = () => host.items(Panel).map((item) => `${item.extension}:main`)
const region = createRegion({
region: "side",
view,
screen,
tabs: () => ({
all,
active: () => input.active,
setAll() {},
setActive() {},
close() {},
remap() {},
}),
})
return (
<Show when={host.ready()}>
<ExtensionSlot
at="session.panel.end"
input={{
get session() {
return view()
},
screen,
}}
/>
<RegionContent
region={region}
view={view()}
screen={screen}
frame={{
shown: () => true,
present: () => true,
placement: () => "side",
reserve: () => false,
animate: () => false,
sidebar,
}}
/>
</Show>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<RealHost
definitions={input.definitions}
captured={captured}
mounted={standIn({ current: () => (routed() ? view() : undefined), screen: () => screen })}
>
<Region />
</RealHost>
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
container,
/** Routes a session: the screen creates a new session object for it. */
route: (id: string) => setView(object(id)),
/** The route leaves while its old screen has not unmounted yet. */
leave: () => setRouted(false),
/** The session each composer attachment reached, in order. */
attached,
status: (id: string) => hosts[0]?.state.status[id],
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host over the real HostApis of a web window, before the app interface mounts. `attach()` mounts a
* stand-in for the interface: its layout records each write and reports a tab it opened as visible, its server is
* the local one, and its mono font is "fixture mono".
*/
export function mountHostApis(input: { definitions: readonly Definition[] }) {
const container = document.createElement("div")
document.body.appendChild(container)
const writes: string[] = []
const [opened, setOpened] = createSignal<readonly string[]>([])
const hosts: ExtensionHost[] = []
const captured: Captured = {}
const layout = standIn({}).layout
const mounted = standIn({
layout: {
...layout,
open(key) {
writes.push(`open ${key}`)
setOpened((keys) => [...keys, key])
},
close: (key) => void writes.push(`close ${key}`),
toggle: (key) => void writes.push(`toggle ${key}`),
state: (key) => (opened().includes(key) ? "visible" : "closed"),
scroll: { get: () => undefined, set: (_session, key) => void writes.push(`scroll ${key}`) },
},
settings: (page) => void writes.push(`settings ${page}`),
})
function Capture() {
hosts.push(useExtensionHost())
return null
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<RealHost definitions={input.definitions} captured={captured}>
<Capture />
</RealHost>
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
/** Layout writes the interface received, in order. */
writes,
/** Mounts the interface. */
attach: () => void captured.apis?.attach(mounted),
status: (id: string) => hosts[0]?.state.status[id],
unmount: () => {
dispose()
container.remove()
},
}
}
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,6 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
import { expect, sourceURL, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
const fixture = sourceURL(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url))
story.beforeEach(async ({ mount }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
@@ -1,7 +1,6 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
mapVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
@@ -242,7 +242,7 @@ test("keeps an older answer selected while scrolling within the interaction buff
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
cpuRate: 4,
@@ -15,7 +15,6 @@ import {
partUpdated,
renderedPartID,
setupTimeline,
shell,
status,
textPart,
toolPart,
@@ -1,4 +1,4 @@
import type { CDPSession, Page } from "@playwright/test"
import type { Page } from "@playwright/test"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
@@ -9,16 +9,32 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
const abandoned = Promise.withResolvers<void>()
const main = { id: "ses_btw_sidebar", title: "Side question session" }
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
const ownerWarnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
ownerWarnings.push(message.text())
})
const { editor } = await openSession(page, {
name: "BtwSidebar",
sessions: [main, other],
onPrompt: (input) => prompts.push(input),
generate: async (input) => {
generations.push(input)
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
if (input.prompt.includes("left behind")) {
await abandoned.promise
return { text: "This answer arrived after the user left." }
}
await generated.promise
return {
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
}
@@ -65,8 +81,21 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
// Leaving a session abandons its in-flight question, so it reads as failed on return.
await editor.fill("/btw is this question left behind?")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(other.id)}"]`).click()
await expectSessionTitle(page, other.title)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
abandoned.resolve()
await page.reload()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
await expect(panel).toHaveCount(0)
expect(ownerWarnings).toEqual([])
})
@@ -10,7 +10,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
fileContent: (path) => `contents:${path}`,
findFiles: ({ query }) =>
["first.ts", "second.ts"].filter((path) => path.includes(query)).map((path) => fileNode(fixture.directory, path)),
["first.ts", "second.ts"].flatMap((path) => (path.includes(query) ? [fileNode(fixture.directory, path)] : [])),
})
await page.goto(sessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
@@ -21,15 +21,19 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
await files.locator('[data-column-number="1"]').click()
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
await expect(editor.getByRole("textbox")).toBeVisible()
const fullWidth = async (card: typeof editor) => {
const panel = (await files.boundingBox())!
const box = await card.boundingBox()
return !!box && Math.abs(box.x - panel.x - 12) < 2 && Math.abs(box.width - panel.width + 24) < 2
}
for (const width of [390, 700]) {
await page.setViewportSize({ width, height: 844 })
await expect.poll(() => fullWidth(editor)).toBe(true)
}
await editor.getByRole("textbox").fill("Full-width file comment")
await editor.getByRole("button", { name: "Comment", exact: true }).click()
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
@@ -111,6 +115,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
],
fileContent: async (path) => {
if (path === "modified.ts") await held.promise
return `contents:${path}`
},
})
@@ -139,11 +144,13 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCount(0)
}
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
const modified = review.locator('[data-file="modified.ts"]')
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
@@ -154,9 +161,11 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
// A line comment is staged into the composer context without writing anything to the server.
const writes: string[] = []
const recordWrite = (request: Request) => {
if (request.method() !== "GET") writes.push(`${request.method()} ${new URL(request.url()).pathname}`)
}
page.on("request", recordWrite)
const note = "Use the existing value instead"
const commented = review.locator('[data-file="src/review.ts"]')
@@ -177,8 +186,12 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
const settings = page.getByTestId("settings-screen")
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
const storedWrap = () =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap)
page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.review.mobileDiff") ?? "{}").wrap,
)
await expect(wrap).toBeChecked()
await wrapControl.click()
await expect(wrap).not.toBeChecked()
@@ -214,6 +227,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
.poll(async () => {
const button = (await openFile.boundingBox())!
const summary = (await modified.locator('[data-slot="session-review-change-summary"]').boundingBox())!
return button.x + button.width < summary.x + summary.width / 2
})
.toBe(true)
@@ -221,12 +235,14 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
// Opening a changed file selects its tab at once, before a slower file finishes loading; reopening switches back.
await review.locator('[data-file="added.ts"]').getByRole("button", { name: "added.ts", exact: true }).click()
const selected = files.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { selected: true })
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(selected).toHaveText([file])
await expect(files).toHaveAttribute("data-browsing", "false")
if (file === "modified.ts") held.resolve()
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
@@ -235,9 +251,11 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
await mockStressTimeline(page)
await page.goto(sessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
@@ -251,9 +269,13 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const bounds = (await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox())!
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
@@ -262,6 +284,7 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
await page.mouse.move(bounds.x + bounds.width / 2, 843)
await page.mouse.up()
}
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
await expect(overlay).toHaveCount(0)
await expect(more).toBeFocused()
+170 -9
View File
@@ -1,7 +1,7 @@
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { expect, test, type Locator, type Page } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { SERVER, project, provider, session, workspaceKey } from "../utils/app"
import { SERVER, holdRoute, project, provider, session, workspaceKey } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fileDiff, fileNode, openSession } from "../utils/workspace"
import { expectSessionTitle } from "../utils/waits"
@@ -12,11 +12,13 @@ for (const view of ["desktop", "mobile"] as const) {
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
const requests: { directory: string; provider?: string }[] = []
const workspace = await openSession(page, {
name: "ReviewWithoutGit",
project: { vcs: undefined },
onVcsInit: (input) => requests.push(input),
})
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
@@ -31,10 +33,90 @@ for (const view of ["desktop", "mobile"] as const) {
})
}
test("Git initialization belongs to its session and refreshes after a session switch", async ({ page }) => {
const requests: { directory: string; provider?: string }[] = []
const workspace = await openSession(page, {
name: "ReviewInitSwitch",
project: { vcs: undefined },
onVcsInit: (input) => requests.push(input),
sessions: [
{ id: "ses_init_a", title: "Initialize Alpha" },
{ id: "ses_init_b", title: "Initialize Beta" },
],
seed: { panes: { ses_init_a: { review: true }, ses_init_b: { review: true } } },
})
// The successful init finishes while another session is routed.
const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" })
const panel = page.locator("#review-panel")
const init = panel.getByRole("button", { name: "Create Git repository", exact: true })
const pending = panel.getByRole("button", { name: "Creating Git repository…", exact: true })
await expect(init).toBeEnabled()
await init.click()
await request.arrived
await expect(pending).toBeDisabled()
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click()
await expectSessionTitle(page, "Initialize Beta")
await expect(init).toBeEnabled()
await expect(pending).toHaveCount(0)
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click()
await expectSessionTitle(page, "Initialize Alpha")
await expect(pending).toBeDisabled()
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click()
await expectSessionTitle(page, "Initialize Beta")
await expect(init).toBeEnabled()
const response = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/vcs/init")
// The worktree event can refresh project-wide UI by itself; the completed action must also reload Alpha's cache.
const refreshed = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/session/ses_init_a")
request.release()
expect((await response).status()).toBe(204)
expect((await refreshed).status()).toBe(200)
expect(requests).toEqual([{ directory: workspace.directory, provider: "git" }])
await expect(panel.getByRole("button", { name: "Git changes", exact: true })).toBeVisible()
await expect(init).toHaveCount(0)
await expect(page.getByText("Request failed", { exact: true })).toHaveCount(0)
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click()
await expectSessionTitle(page, "Initialize Alpha")
await expect(panel.getByRole("button", { name: "Git changes", exact: true })).toBeVisible()
await expect(pending).toHaveCount(0)
})
test("a rejected Git init releases its session's button without showing an error in another session", async ({ page }) => {
await openSession(page, {
name: "ReviewInitFailure",
project: { vcs: undefined },
sessions: [
{ id: "ses_init_failure_a", title: "Initialize Alpha" },
{ id: "ses_init_failure_b", title: "Initialize Beta" },
],
seed: { panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } } },
})
const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" })
const init = page.locator("#review-panel").getByRole("button", { name: "Create Git repository", exact: true })
await init.click()
await request.arrived
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click()
await expectSessionTitle(page, "Initialize Beta")
await expect(init).toBeEnabled()
const response = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/vcs/init")
request.release()
expect((await response).status()).toBe(501)
await expect(page.getByText("Request failed", { exact: true })).toHaveCount(0)
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click()
await expectSessionTitle(page, "Initialize Alpha")
await expect(init).toBeEnabled()
})
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
const directory = "C:/OpenCode/ReviewOpenFile"
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
const workspace = await openSession(page, {
name: "ReviewOpenFile",
vcsDiff: [fileDiff("src/changed.ts")],
@@ -49,6 +131,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? [fileNode(directory, "src/nested.ts")] : []
},
seed: {
@@ -90,6 +173,8 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
route.fulfill({
status: 404,
headers: { "access-control-allow-origin": "*" },
// SAFETY: the server's wire body for a missing file, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "FileNotFoundError", path: "README.md", message: "File not found: README.md" },
}),
)
@@ -142,9 +227,10 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
.toBeLessThanOrEqual(1)
const scrolled = await viewport.evaluate((element) => element.scrollTop)
expect(scrolled).toBeGreaterThan(0)
await root.evaluate((element) => ((element as HTMLElement & { e2eProbe?: string }).e2eProbe = "original"))
await root.evaluate((element) => void (element.dataset.e2eProbe = "original"))
const expectSidebarKept = async () => {
expect(await root.evaluate((element) => (element as HTMLElement & { e2eProbe?: string }).e2eProbe)).toBe("original")
expect(await root.evaluate((element) => element.dataset.e2eProbe)).toBe("original")
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
}
@@ -269,7 +355,9 @@ test("file tree expands Windows paths and scrolls long names in both directions"
{ ...fileNode(directory, longPath), path: `frontend\\${longFilename}` },
]
}
if (path) return []
return [
{ ...fileNode(directory, "frontend", "directory"), name: "", path: "frontend\\" },
fileNode(directory, "README.md"),
@@ -371,6 +459,7 @@ test("a restored file tab keeps the file tree on Files Changed", async ({ page }
test("rereads a file each time an artifact link opens it", async ({ page }) => {
const file = { content: "first draft" }
const messages = [
{ id: "msg_prompt", type: "user", text: "Write the shopping list", time: { created: 1 } },
{
@@ -382,6 +471,7 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
await openSession(page, {
name: "ArtifactReopen",
fileList: () => [],
@@ -405,6 +495,9 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
})
/** What the image case's frame probe records on the window. */
type ImageProbe = { problems: string[]; frames: number; running: boolean }
test("image files keep the review panel painted while loading", async ({ page }) => {
await page.setViewportSize({ width: 960, height: 900 })
const directory = "C:/OpenCode/ReviewImage"
@@ -418,12 +511,16 @@ test("image files keep the review panel painted while loading", async ({ page })
if (path !== image) return undefined
read.resolve()
await release.promise
return { type: "binary", content: "iVBORw0KGgo=", encoding: "base64", mimeType: "image/png" }
},
fileList: (path) => {
if (!path) return [fileNode(directory, "assets", "directory"), fileNode(directory, "src", "directory")]
if (path === "assets") return [fileNode(directory, image)]
if (path === "src") return [fileNode(directory, "src/example.ts")]
return []
},
})
@@ -432,27 +529,37 @@ test("image files keep the review panel painted while loading", async ({ page })
// Records every painted frame from before the click until after the image read completes.
await page.evaluate(() => {
const probe = { problems: [] as string[], frames: 0, running: true }
const probe: ImageProbe = { problems: [], frames: 0, running: true }
const sample = () => {
const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
const rect = panel?.getBoundingClientRect()
const hit =
rect?.width && rect.height ? document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) : null
const frame = [
!panel?.checkVisibility() && "hidden",
!panel?.textContent?.trim() && "blank",
!(hit && document.querySelector("#review-panel")?.contains(hit)) && "outside the review panel",
hit && getComputedStyle(hit).backgroundColor === "rgb(0, 0, 0)" && "black",
].filter((problem) => typeof problem === "string")
].filter((problem) => problem !== false && problem !== null)
probe.problems.push(...frame)
probe.frames += 1
if (probe.running) requestAnimationFrame(sample)
}
;(window as Window & { e2eImageProbe?: typeof probe }).e2eImageProbe = probe
// The evaluates below read it.
Object.assign(window, { e2eImageProbe: probe })
requestAnimationFrame(sample)
})
const frames = () =>
page.evaluate(() => (window as Window & { e2eImageProbe?: { frames: number } }).e2eImageProbe!.frames)
// SAFETY: the evaluate above stored the probe on the window before any frame is counted.
page.evaluate(() => (window as Window & { e2eImageProbe?: ImageProbe }).e2eImageProbe!.frames)
const response = page.waitForResponse((item) => item.url().includes(`/api/fs/read/${image}`))
await page.getByRole("button", { name: /preview\.png/ }).click()
await read.promise
@@ -465,8 +572,10 @@ test("image files keep the review panel painted while loading", async ({ page })
await expect.poll(frames).toBeGreaterThan(loaded)
expect(
await page.evaluate(() => {
const probe = (window as Window & { e2eImageProbe?: { problems: string[]; running: boolean } }).e2eImageProbe!
// SAFETY: the evaluate above stored the probe on the window.
const probe = (window as Window & { e2eImageProbe?: ImageProbe }).e2eImageProbe!
probe.running = false
return probe.problems
}),
).toEqual([])
@@ -478,6 +587,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
{ id: "ses_review_state_b", title: "Beta review state" },
{ id: "ses_review_state_c", title: "Gamma review state" },
]
const directory = "C:/OpenCode/ReviewState"
await openSession(page, {
name: "ReviewState",
@@ -493,10 +603,13 @@ test("restores review state and the side-panel tab per session", async ({ page }
const panel = page.locator("#review-panel")
const review = panel.locator("#session-side-panel-review-tab")
const toggle = page.getByRole("button", { name: "Toggle review" })
const selectedTab = (name: string) =>
expect(panel.getByRole("tab", { name, exact: true })).toHaveAttribute("aria-selected", "true")
const selectedFile = (file: string) =>
expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
const switchSession = async (title: string) => {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
@@ -603,6 +716,7 @@ test("keeps the review state a session stored before extensions", async ({ page
})
const mode = (name: string) => page.getByRole("button", { name, exact: true })
const changes = page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Changes" })
const trigger = (file: string) =>
page
.locator(`[data-component="session-review"] [data-file="${file}"]`)
@@ -643,8 +757,10 @@ test("desktop review waits for the session's stored mode before it loads changes
const working: string[] = []
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname.endsWith("/vcs/diff") && url.searchParams.get("mode") === "working") working.push(request.url())
})
const desktop = (hold?: string) =>
`/e2e/utils/settings-wsl.html?${new URLSearchParams({
server: SERVER,
@@ -653,6 +769,7 @@ test("desktop review waits for the session's stored mode before it loads changes
path: `/server/${base64Encode("sidecar")}/session/${id}`,
...(hold && { hold }),
})}`
const mode = (name: string) => page.getByRole("button", { name, exact: true })
const file = page.locator('[data-slot="session-review-v2-file-name"]')
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
@@ -686,6 +803,36 @@ test("desktop review waits for the session's stored mode before it loads changes
expect(working).toEqual([])
})
test("a chosen mode the session stops offering shows as Git, and comes back when it is offered again", async ({
page,
}) => {
// The mock server reads the branches on each request, so a reload sees the session's branch change.
const vcs = { current: "feature", default: "dev" }
await openSession(page, {
name: "ReviewModeBack",
vcs,
vcsDiff: ({ mode }) => (mode === "branch" ? [fileDiff("beta.ts")] : [fileDiff("gamma.ts")]),
})
const mode = (name: string) => page.getByRole("button", { name, exact: true })
const file = page.locator('[data-slot="session-review-v2-file-name"]')
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await mode("Git changes").click()
await page.getByRole("option", { name: "Branch changes" }).click()
await expect(file).toHaveText("beta.ts")
// Back on the default branch, Branch is not offered.
vcs.current = "dev"
await page.reload()
await expect(mode("Git changes")).toBeVisible()
await expect(file).toHaveText("gamma.ts")
vcs.current = "feature"
await page.reload()
await expect(mode("Branch changes")).toBeVisible()
await expect(file).toHaveText("beta.ts")
})
for (const direction of ["ltr", "rtl"] as const) {
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1000, height: 900 })
@@ -708,6 +855,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect
.poll(async () => {
const box = (await panel.boundingBox())!
return (
closed.x >= box.x &&
closed.x + closed.width <= box.x + box.width &&
@@ -719,6 +867,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect
.poll(async () => {
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
return box ? box.y + box.height / 2 : undefined
})
.toBe(closed.y + closed.height / 2)
@@ -731,7 +880,9 @@ for (const direction of ["ltr", "rtl"] as const) {
// Pause in the same task as each click so even the first painted state can be inspected.
for (const opened of [true, false]) {
await toggle.evaluate((element) => {
;(element as HTMLButtonElement).click()
// SAFETY: `toggle` is the review toggle, a button.
const button = element as HTMLButtonElement
button.click()
document
.getAnimations()
.filter((animation) => animation.timeline instanceof DocumentTimeline)
@@ -739,6 +890,7 @@ for (const direction of ["ltr", "rtl"] as const) {
})
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
await expect(panel).toHaveAttribute("aria-hidden", String(!opened))
for (const progress of [0.25, 0.8]) await expectHeaderClearOfToggle(page, toggle, progress)
await page.evaluate(() =>
document
@@ -747,6 +899,7 @@ for (const direction of ["ltr", "rtl"] as const) {
.forEach((animation) => animation.finish()),
)
}
await expect(panel).toBeHidden()
})
}
@@ -764,6 +917,7 @@ async function statusInset(status: Locator, side: "left" | "right") {
return status.evaluate((element, side) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
const box = element.getBoundingClientRect()
return side === "right" ? viewport.right - box.right : box.left - viewport.left
}, side)
}
@@ -781,18 +935,22 @@ function filesystemEvent(directory: string, file: string, event: "add" | "change
async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress: number) {
const geometry = await page.locator('[data-slot="session-chat-panel"]').evaluate((chat, progress) => {
const row = chat.parentElement!
const animations = row
.getAnimations({ subtree: true })
.filter((animation) => animation.timeline instanceof DocumentTimeline)
const width = animations.find(
(animation) => animation instanceof CSSTransition && animation.transitionProperty === "width",
)!
animations.forEach((animation) => {
animation.pause()
animation.currentTime = Number(width.effect!.getTiming().duration) * progress
})
const chatBounds = chat.getBoundingClientRect()
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
return {
row: row.getBoundingClientRect().width,
panelWidth: panelBounds.width,
@@ -804,8 +962,11 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
panels: chatBounds.width + panelBounds.width + parseFloat(getComputedStyle(row).columnGap),
}
}, progress)
expect(geometry.gap).toBeCloseTo(8, 1)
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
if (progress === 0.25) {
expect(geometry.contentOpacity).toBeGreaterThan(0)
expect(geometry.contentOpacity).toBeLessThan(1)
@@ -23,23 +23,30 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
let requests = 0
await page.route("**/api/**", (route) => {
const url = new URL(route.request().url())
if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory)
return route.fallback()
requests++
if (recover && requests > 1) return route.fallback()
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
})
const failure = page.waitForResponse(
(response) => new URL(response.url()).pathname === endpoint && response.status() === 500,
)
const settled = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === endpoint &&
url.searchParams.get("location[directory]") === directory &&
(recover ? response.ok() : requests === 3 && response.status() === 500)
)
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await failure
await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible()
@@ -86,10 +93,12 @@ test("follows a live session move while the agent catalog is still loading", asy
})
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) {
requested.resolve()
await release.promise
}
return route.fallback()
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
@@ -99,10 +108,13 @@ test("follows a live session move while the agent catalog is still loading", asy
await prompt.fill("Keep this draft")
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
})
session.directory = destination
await transport.send({
id: "evt_location_moved_while_loading",
@@ -112,10 +124,13 @@ test("follows a live session move while the agent catalog is still loading", asy
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await resolved
const delayed = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok()
})
release.resolve()
await delayed
await expect(prompt).toBeEditable()
@@ -127,6 +142,76 @@ test("follows a live session move while the agent catalog is still loading", asy
expect(recovery).toEqual([])
})
test("keeps the side panel's tabs and selection when the session moves", async ({ page }) => {
const directory = "/projects/tabs-before-move"
const destination = "/projects/tabs-after-move"
const sessionID = "ses_location_moved_tabs"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved tabs" }
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
fileList: () => [],
findFiles: (input) => ["README.md", "NOTES.md"].filter((name) => name.startsWith(input.query)),
fileContent: (path) => ({ type: "text", content: `contents:${session.directory}:${path}` }),
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Keep my tabs", time: { created: 1 } }],
}),
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
const panel = page.getByRole("complementary", { name: "Review and files" })
const files = panel.getByRole("tab", { name: /\.md$/ })
const notes = panel.getByRole("tab", { name: "NOTES.md", exact: true })
// A lost selection would fall back to the first file tab, README.md, so NOTES.md stays selected only if it carries.
for (const name of ["README", "NOTES"]) {
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
const search = dialog.getByRole("textbox")
await search.fill(name)
await expect(dialog.getByRole("option", { name: `/ ${name}.md`, exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await search.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(panel.getByRole("tab", { name: `${name}.md`, exact: true })).toHaveAttribute("aria-selected", "true")
}
await expect(files).toHaveText(["README.md", "NOTES.md"])
await expect(panel.getByText(`contents:${directory}:NOTES.md`, { exact: true })).toBeVisible()
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.send({
id: "evt_location_moved_tabs",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await resolved
await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible()
await expect(files).toHaveText(["README.md", "NOTES.md"])
await expect(notes).toHaveAttribute("aria-selected", "true")
// The carried tab shows the file in the new directory.
await expect(panel.getByText(`contents:${destination}:NOTES.md`, { exact: true })).toBeVisible()
})
test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/before-reconnect"
@@ -149,12 +234,15 @@ test("refreshes a session moved during disconnection without losing the draft",
await expect(prompt).toBeEditable()
await prompt.fill("Draft before disconnect")
const connection = await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.close()
await transport.waitForConnection({ after: connection.id })
@@ -186,9 +274,11 @@ test("ignores an old failed location read after reconnecting", async ({ page })
await page.route("**/api/location?**", async (route) => {
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
requests++
if (requests > 1) return route.fallback()
requested.resolve()
await release.promise
return route.fulfill({
status: 500,
body: "",
@@ -202,12 +292,15 @@ test("ignores an old failed location read after reconnecting", async ({ page })
await prompt.fill("Keep typing here")
const connection = await transport.waitForConnection()
const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`)
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok()
)
})
await transport.close()
await transport.waitForConnection({ after: connection.id })
await metadata
@@ -227,7 +320,9 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move|worktree)$/.test(path)) requests.push(path)
})
return requests
}
@@ -10,7 +10,8 @@ for (const custom of [false, true]) {
test(`summary toggle ${custom ? "custom" : "default"} shortcut follows the active session`, async ({ page }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page)
// The legacy command ID; the keybind migration renames it to `summary.toggle`.
// The legacy command ID; the keybind migration renames it to `details.toggle`.
if (custom) await seed(page, { settings: { keybinds: { "session.summary.toggle": "f8" } } })
await page.goto(sessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
@@ -25,6 +26,7 @@ for (const custom of [false, true]) {
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
)
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(id)}"]`).click()
const messages = id === fixture.sourceID ? fixture.expected.sourceMessageIDs : fixture.expected.targetMessageIDs
@@ -41,6 +43,18 @@ for (const custom of [false, true]) {
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
}
if (!custom) return
// The override moved, so storage keeps only its new id and Shortcuts lists no row titled with the old one.
const keybinds = () => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "null")?.keybinds)
await expect.poll(keybinds).toEqual({ "details.toggle": "f8" })
await page.goto("/settings")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
await expect(settings.getByText("Toggle summary", { exact: true })).toBeVisible()
await expect(settings.getByText("session.summary.toggle", { exact: true })).toHaveCount(0)
expect(await keybinds()).toEqual({ "details.toggle": "f8" })
})
}
@@ -74,7 +88,7 @@ test("summary disclosures import legacy settings and persist in extension storag
await expect(trigger).toBeFocused()
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.summary.prefs") ?? "null")),
page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:extension.details.prefs") ?? "null")),
)
.toEqual({ projectExpanded: true, serverExpanded: false })
@@ -120,6 +134,49 @@ test("a session in a worktree subfolder names its worktree and lists cached work
list.release()
})
test("the details follow the routed session when it moves to another worktree", async ({ page }) => {
const root = "C:/OpenCode/SmokeWorktrees"
const sessions = fixture.sessions.map((item) =>
item.id === fixture.targetID ? { ...item, directory: `${root}/feature` } : { ...item },
)
const mock = await mockStressTimeline(page, {
sessions,
worktrees: [
{ directory: fixture.directory },
{ directory: `${root}/feature`, strategy: "git" },
{ directory: `${root}/other`, strategy: "git" },
],
})
await page.goto(sessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const location = (name: string) => summary.getByRole("button", { name, exact: true })
await trigger.click()
await expect(location("feature")).toBeVisible()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
// The server moves the session; the screen stays mounted while its extensions read the new worktree.
const moved = sessions.find((item) => item.id === fixture.targetID)
if (moved) moved.directory = `${root}/other`
await mock.push([
{
id: "evt_details_session_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: fixture.targetID, seq: 1, version: 1 },
data: { sessionID: fixture.targetID, location: { directory: `${root}/other` }, projectID: fixture.project.id },
},
])
await trigger.click()
await expect(location("other")).toBeVisible()
await expect(location("feature")).toHaveCount(0)
})
for (const direction of ["ltr", "rtl"] as const) {
test(`summary overlays the view and submenus follow ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
@@ -131,9 +188,11 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const warnings = ownerWarnings(page)
const row = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.locator('[data-component="session-composer-dock"] > div')
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(row).toBeInViewport()
@@ -145,6 +204,7 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(async () => {
const message = (await row.boundingBox())!
const input = (await composer.boundingBox())!
return Math.max(Math.abs(message.x - before.row.x), Math.abs(input.x - before.composer.x))
})
.toBeLessThan(1)
@@ -152,6 +212,7 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(async () => {
const message = (await row.boundingBox())!
const details = (await summary.boundingBox())!
return Math.min(message.x + message.width, details.x + details.width) - Math.max(message.x, details.x)
})
.toBeGreaterThan(0)
@@ -160,10 +221,12 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
// Labels, including the truncated branch, stop 12px before the trailing indicator column.
const workspace = summary.getByRole("button", { name: "Local repository", exact: true })
for (const [label, indicator] of [
[text, workspace.locator(".session-summary-menu-indicator")],
...["Local repository", "MCP", "Plugins", "Skills", "LSP"].map((name) => {
const item = summary.getByRole("button", { name, exact: true })
return [item.locator(".session-summary-label"), item.locator(".session-summary-menu-indicator")]
}),
]) {
@@ -171,10 +234,12 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(async () => {
const start = (await label!.boundingBox())!
const end = (await indicator!.boundingBox())!
return direction === "ltr" ? end.x - start.x - start.width : start.x - end.x - end.width
})
.toBeGreaterThanOrEqual(12)
}
await expectAlignedWithHeader(page, direction)
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
@@ -188,6 +253,7 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(async () => {
const item = (await mcp.boundingBox())!
const menu = (await submenu.boundingBox())!
return direction === "ltr" ? menu.x + menu.width <= item.x : menu.x >= item.x + item.width
})
.toBe(true)
@@ -209,6 +275,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(page.getByRole("dialog", { name, exact: true }).getByText(empty, { exact: true })).toBeVisible()
await expect(submenu).toBeHidden()
}
await summary.getByRole("button", { name: "Extensions", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
@@ -224,6 +291,7 @@ for (const direction of ["ltr", "rtl"] as const) {
test("summary catalogs load, refresh while cached, and tell errors from empty", async ({ page }) => {
const state = { fail: true, extra: false, empty: false }
const pluginDirectories: string[] = []
const lsp = [
{
type: "document",
@@ -236,6 +304,7 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
},
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
]
await mockStressTimeline(page, {
mcp: () => (state.empty ? [] : [{ name: "summary-mcp", status: { status: "connected" } }]),
plugins: () =>
@@ -280,7 +349,9 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
pluginDirectories.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
if (state.fail) return route.fulfill({ status: 500, headers: cors, json: { message: "Unavailable" } })
return route.fallback()
},
)
@@ -337,6 +408,7 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
state.empty = true
lsp.length = 0
for (const service of [
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured" },
@@ -347,6 +419,7 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
await expect(menu(service.name).getByText(service.empty, { exact: true })).toBeVisible()
await expectRefreshKeeps(page, service.name, service.path, service.empty)
}
expect(warnings).toEqual([])
})
@@ -363,6 +436,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
const writes: { path: string; directory: string | null }[] = []
page.on("request", (request) => {
const url = new URL(request.url())
if (request.method() !== "POST" || !url.pathname.startsWith("/api/experimental/mcp/")) return
writes.push({ path: url.pathname, directory: url.searchParams.get("location[directory]") })
})
@@ -371,9 +445,11 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
const row = submenu
.locator('[data-component="switch"]')
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
await expect(toggle).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
@@ -386,14 +462,20 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
const enabled = index % 2 !== 0
await expect(toggle).toBeEnabled()
if (target === "label") await row.getByText("figma", { exact: true }).click()
if (target === "dot") await row.locator(".session-service-dot").click()
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
if (target === "keyboard") await toggle.press("Space")
await expect(toggle).toBeChecked({ checked: enabled })
await expect(toggle).toBeEnabled()
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toEqual({
@@ -411,6 +493,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
mcp: [{ name: "linear", integrationID: "linear-oauth", status: { status: "disabled" } }],
onMcpAction: () => {
state.connected = true
return { status: "needs_auth" }
},
integrations: [{ id: "linear-oauth", name: "Linear", methods: [{ id: "oauth", type: "oauth" }], connections: [] }],
@@ -420,16 +503,19 @@ test("MCP authentication starts before a slow resource catalog finishes", async
if (request.method() === "POST" && new URL(request.url()).pathname.startsWith("/api/integration/"))
attempts.push(request.url())
})
// Only the resource catalog refresh after connecting is slow.
const resources = await holdRoute(page, (url) => state.connected && url.pathname === "/api/mcp/resource", {
method: "GET",
})
await page.goto(sessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
await expect(toggle).toBeEnabled()
try {
const popup = page.waitForEvent("popup")
await submenu.getByText("linear", { exact: true }).click()
@@ -440,6 +526,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
} finally {
resources.release()
}
await expect(toggle).toBeEnabled()
expect(attempts).toHaveLength(1)
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
@@ -475,6 +562,7 @@ test.describe("remote configuration", () => {
test("remote servers copy each service's configuration path", async ({ page }) => {
const remote = "http://summary-remote.test:4096"
const home = "/home/remote/.config/opencode"
const entries = [
{ type: "document", path: `${home}/mcp.jsonc`, info: { mcp: { servers: {} } } },
{ type: "document", path: `${home}/plugins.json`, info: { plugins: [] } },
@@ -483,6 +571,7 @@ test.describe("remote configuration", () => {
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
]
await mockOpenCodeServer(page, {
server: remote,
sessions: fixture.sessions,
@@ -512,15 +601,18 @@ test.describe("remote configuration", () => {
await expect(tooltip).toHaveCount(0)
await copy.hover()
await expect(tooltip).toHaveText("Copy")
if (index === 0) {
await expect
.poll(async () => {
const icon = (await copy.locator("svg").boundingBox())!
const tip = (await tooltip.boundingBox())!
return Math.abs(tip.x + tip.width / 2 - icon.x - icon.width / 2)
})
.toBeLessThanOrEqual(1)
}
await copy.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(service.path)
await expect(tooltip).toHaveText("Copied")
@@ -533,9 +625,11 @@ test.describe("remote configuration", () => {
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
await trigger.click()
await summary.getByRole("button", { name: "Skills", exact: true }).click()
const copy = page
.getByRole("dialog", { name: "Skills", exact: true })
.getByRole("button", { name: "Copy configuration file path", exact: true })
await copy.click()
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
await expect(copy).toBeEnabled()
@@ -562,6 +656,7 @@ test("long MCP lists stay in the viewport in a real RTL locale", async ({ page }
await expect
.poll(async () => {
const bounds = (await menu.boundingBox())!
return bounds.x >= 15 && bounds.y >= 15 && bounds.x + bounds.width <= 785 && bounds.y + bounds.height <= 585
})
.toBe(true)
@@ -579,6 +674,7 @@ function ownerWarnings(page: Page) {
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
return warnings
}
@@ -589,6 +685,7 @@ async function expectAlignedWithHeader(page: Page, direction: "ltr" | "rtl") {
.poll(async () => {
const header = (await page.locator("[data-session-title]").boundingBox())!
const panel = (await summary.boundingBox())!
return direction === "ltr"
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
: Math.abs(panel.x - header.x - 12)
@@ -30,6 +30,7 @@ for (const mode of ["failed", "stopped", "ready"] as const) {
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
}
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
@@ -64,10 +65,33 @@ test("adding a WSL server while the WSL extension is down shows that WSL is unav
await expect(dialog.getByText("WSL is unavailable", { exact: true })).toBeVisible()
})
test("adding an SSH server while the SSH extension is down fails instead of connecting forever", async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/repo",
project: project({ id: "proj_ssh_unavailable", directory: "/repo", name: "SSH project" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1)
await page.getByRole("checkbox", { name: "SSH extension" }).uncheck()
await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter")
await page.getByRole("menuitem", { name: "Add SSH server", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("alert")).toHaveText("Request failed")
await expect(dialog.getByRole("button", { name: "Add server", exact: true })).toBeEnabled()
})
test("an open session's terminal follows its WSL server to the endpoint it restarts on", async ({ page }) => {
const restarted = "http://127.0.0.1:4098"
const directory = "/home/ubuntu/project"
const wsl = session({ id: "ses_wsl", directory, title: "WSL session" })
const config = {
directory,
project: project({ id: "proj_wsl", directory }),
@@ -75,11 +99,13 @@ test("an open session's terminal follows its WSL server to the endpoint it resta
sessions: [wsl],
pageMessages: () => ({ items: [] }),
}
const servers = await mockServers(page, {
[SERVER]: { ...config, sessions: [] },
[REMOTE_SERVER]: { ...config, pty: { prefix: "pty_before" } },
[restarted]: { ...config, pty: { prefix: "pty_after" } },
})
const path = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}`
await page.goto(
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", wsl: REMOTE_SERVER, restart: restarted, path })}`,
@@ -107,6 +133,7 @@ test("an open session's terminal follows its WSL server to the endpoint it resta
test("WSL session and draft tabs outlive the extension going away until the server is removed", async ({ page }) => {
const directory = "/home/ubuntu/project"
const wsl = session({ id: "ses_wsl_tabs", directory, title: "WSL tabs session" })
const config = {
directory,
project: project({ id: "proj_wsl_tabs", directory }),
@@ -114,6 +141,7 @@ test("WSL session and draft tabs outlive the extension going away until the serv
sessions: [wsl],
pageMessages: () => ({ items: [] }),
}
await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config })
const href = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}`
await page.goto(
@@ -126,11 +154,13 @@ test("WSL session and draft tabs outlive the extension going away until the serv
await editor.fill("keep this draft")
await expect(editor).toHaveText("keep this draft")
const tabs = page.locator("a[data-titlebar-tab-link]")
const expectTabs = async () => {
await expect(tabs).toHaveCount(2)
await expect(tabs.nth(0)).toHaveAttribute("href", href)
await expect(tabs.nth(1)).toHaveAttribute("href", /^\/new-session\?draftId=/)
}
await expectTabs()
const extension = page.getByRole("checkbox", { name: "WSL extension" })
@@ -155,6 +185,7 @@ test("WSL session and draft tabs outlive the extension going away until the serv
test("an SSH host that asks for sign-in again opens the dialog once per selected tab", async ({ page }) => {
const directory = "/home/box/project"
const box = session({ id: "ses_ssh_offer", directory, title: "SSH offer session" })
const config = {
directory,
project: project({ id: "proj_ssh_offer", directory }),
@@ -162,6 +193,7 @@ test("an SSH host that asks for sign-in again opens the dialog once per selected
sessions: [box],
pageMessages: () => ({ items: [] }),
}
await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config })
const path = `/server/${base64Encode("ssh:box")}/session/${box.id}`
await page.goto(
@@ -199,14 +231,17 @@ test("an SSH host that asks for sign-in again opens the dialog once per selected
test("an open session moves to the controller a new SSH sign-in creates", async ({ page }) => {
const directory = "/home/box/project"
const model = { id: "box-model", name: "Box Model" }
const box = session({
id: "ses_ssh",
directory,
title: "SSH session",
model: { id: model.id, providerID: "opencode" },
})
const remote = { password: "ssh-1" }
const prompts: unknown[] = []
const config = {
directory,
project: project({ id: "proj_ssh", directory }),
@@ -214,6 +249,7 @@ test("an open session moves to the controller a new SSH sign-in creates", async
sessions: [box],
pageMessages: () => ({ items: [] }),
}
const servers = await mockServers(page, {
[SERVER]: { ...config, sessions: [] },
[REMOTE_SERVER]: {
@@ -222,6 +258,7 @@ test("an open session moves to the controller a new SSH sign-in creates", async
onPrompt: (input) => prompts.push(input.body.text),
},
})
const path = `/server/${base64Encode("ssh:box")}/session/${box.id}`
await page.goto(
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", ssh: REMOTE_SERVER, path })}`,
+73 -1
View File
@@ -13,11 +13,13 @@ import {
} from "../utils/app"
import { mockServers } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockWorkspace } from "../utils/workspace"
import { fileNode, mockWorkspace, openSession } from "../utils/workspace"
import { expectSessionTitle } from "../utils/waits"
const a = { id: "ses_tab_a", title: "Tab A session" }
const b = { id: "ses_tab_b", title: "Tab B session" }
const c = { id: "ses_tab_c", title: "Tab C session" }
test.use({ serviceWorkers: "block" })
@@ -28,6 +30,7 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
sessions: [a, b],
seed: { tabs: [a.id, { draft: "draft_tab_width", directory: "C:/OpenCode/Tabs" }, b.id] },
})
await page.goto(sessionHref(a.id))
const tabs = page.locator("[data-titlebar-tab-slot]")
@@ -36,6 +39,7 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
@@ -43,6 +47,7 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
const link = page.locator(`a[data-titlebar-tab-link][href="${sessionHref(b.id, workspace.server)}"]`)
const box = await link.boundingBox()
if (!box) throw new Error("tab link has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
@@ -118,6 +123,7 @@ for (const row of [
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
@@ -156,11 +162,13 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
const handle = sidebar.locator('[data-component="resize-handle"]')
for (const [offset, width] of [
[-80, "180px"],
[-200, "140px"],
] as const) {
const box = await handle.boundingBox()
if (!box) throw new Error("vertical tab resize handle has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
@@ -168,6 +176,7 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", width)
}
for (const name of ["Home", "New session"]) {
const label = sidebar.getByRole("button", { name, exact: true }).getByText(name, { exact: true })
await expect(label).toBeVisible()
@@ -242,8 +251,11 @@ test("inactive tabs load attention, but read transcript and inbox only on select
})
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (!path.startsWith("/api/")) return
if (request.method() === "GET") reads.push(path)
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(path)
})
const state = { text: "Original fixture answer" }
@@ -267,6 +279,7 @@ test("inactive tabs load attention, but read transcript and inbox only on select
lastProject: { local: fixture.directory },
tabs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
const attention = Promise.all(
[fixture.targetID, fixture.childID].flatMap((id) =>
["permission", "form"].map((kind) =>
@@ -274,6 +287,7 @@ test("inactive tabs load attention, but read transcript and inbox only on select
),
),
)
await page.goto(sessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.sourceID}_assistant:text:0"]`)).toContainText(
@@ -292,10 +306,12 @@ test("inactive tabs load attention, but read transcript and inbox only on select
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.targetID}_assistant:text:0"]`)).toContainText(
state.text,
)
for (const id of [fixture.sourceID, fixture.targetID]) {
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
}
expect(
reads.filter(
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
@@ -307,12 +323,14 @@ test("inactive tabs load attention, but read transcript and inbox only on select
test("five loaded workspace tabs stay rendered and reactive through repeated switches", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const sessions = Array.from({ length: 5 }, (_, index) => ({
...fixture.sessions[0]!,
id: `ses_workspace_cycle_${index}`,
directory: `${fixture.directory}/worktree-${index}`,
title: `Workspace session ${index}`,
}))
const mock = await mockStressTimeline(page, {
sessions,
pageMessages: (id) => ({
@@ -329,6 +347,7 @@ test("five loaded workspace tabs stay rendered and reactive through repeated swi
] satisfies SessionMessageInfo[],
}),
})
// Each worktree session resolves to its own location in the shared project (the mock echoes the requested one).
await seed(page, {
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
@@ -342,6 +361,7 @@ test("five loaded workspace tabs stay rendered and reactive through repeated swi
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${item.id}:text:0"]`)).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
const active = sessions[0]!
await mock.push([
{
@@ -361,6 +381,56 @@ test("five loaded workspace tabs stay rendered and reactive through repeated swi
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
})
test("each session tab shows its own file tab after a switch to a session in another folder", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/FolderSwitch"
const alpha = { id: "ses_folderswitch_alpha", title: "Folder switch alpha" }
const beta = { id: "ses_folderswitch_beta", title: "Folder switch beta" }
const other = { id: "ses_folderswitch_other", title: "Folder switch other", directory: "C:/OpenCode/FolderOther" }
await openSession(page, {
name: "FolderSwitch",
sessions: [alpha, beta, other],
fileList: (path) => (path ? [] : ["greet.ts", "guide.md", "notes.txt"].map((file) => fileNode(directory, file))),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: { panes: Object.fromEntries([alpha, beta, other].map((item) => [item.id, { review: true }])) },
})
const panel = page.locator("#review-panel")
const shows = async (file: string) => {
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
const visit = async (target: { id: string; title: string }) => {
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(target.id)}"]`).click()
await expectSessionTitle(page, target.title)
}
const open = async (file: string) => {
await panel.getByRole("button", { name: "Open file" }).click()
await panel.getByRole("button", { name: file, exact: true }).click()
await shows(file)
}
await open("greet.ts")
await visit(other)
await open("notes.txt")
await visit(beta)
await open("guide.md")
// The session screen stays mounted while the route moves between folders, and each tab reads its own folder.
for (const [target, file] of [
[alpha, "greet.ts"],
[other, "notes.txt"],
[beta, "guide.md"],
[other, "notes.txt"],
[alpha, "greet.ts"],
] as const) {
await visit(target)
await shows(file)
}
})
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
@@ -384,6 +454,7 @@ async function twoServers(
) {
const config = (sessions: ReturnType<typeof session>[], id: string) => {
const directory = String(sessions[0]!.directory)
return {
directory,
project: project({ id, directory }),
@@ -393,6 +464,7 @@ async function twoServers(
strictDirectory: true,
}
}
await mockServers(page, {
[SERVER]: config(input.a, "proj_server_a"),
[REMOTE_SERVER]: {
@@ -48,7 +48,9 @@ test("routes typing to the composer unless the open terminal is focused", async
await expect.poll(() => pty.sockets.length).toBe(1)
pty.send("ready")
await expect(terminal).toHaveCSS("opacity", "1")
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
})
await page.keyboard.type("a")
await expect(editor).toBeFocused()
await expect(editor).toHaveText("a")
@@ -64,6 +66,7 @@ for (const mount of ["cached", "explicit"] as const) {
await route.continue()
})
const cached = { id: "pty_cached", title: "Terminal 1" }
const { pty, sessions } = await mockWorkspace(page, {
...workspace,
pty: mount === "cached" ? { initial: [cached] } : {},
@@ -76,14 +79,17 @@ for (const mount of ["cached", "explicit"] as const) {
}
: undefined,
})
await page.goto(sessionHref(sessions[0]!.id), { waitUntil: "commit" })
await expectSessionTitle(page, "TerminalInput")
const editor = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
if (mount === "explicit") {
await expect(editor).toBeEditable()
await page.keyboard.press("Control+Backquote")
}
await expect(terminal).toBeVisible()
await ghostty.promise
await editor.click()
@@ -95,6 +101,7 @@ for (const mount of ["cached", "explicit"] as const) {
pty.send("ready")
await expect(terminal).toHaveCSS("opacity", "1")
await expect(editor).toBeFocused()
if (mount === "cached") expect(pty.created).toEqual([])
})
}
@@ -113,8 +120,40 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
test("closing the last restored terminal does not create a hidden replacement", async ({ page }) => {
const cached = { id: "pty_cached", title: "Terminal 1" }
const { pty } = await openSession(page, {
...workspace,
pty: { initial: [cached] },
seed: {
panes: { ses_terminalinput: { terminal: true, terminalHeight: 320 } },
storage: { [legacyTerminalKey()]: { active: cached.id, all: [{ ...cached, titleNumber: 1 }] } },
},
})
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => pty.sockets.length).toBe(1)
expect(pty.created).toEqual([])
await page.locator("#terminal-panel").getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(terminal).toBeHidden()
await expect.poll(() => pty.removed).toEqual([cached.id])
expect(pty.created).toEqual([])
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => pty.created.map((item) => item.id)).toEqual(["pty_1"])
await expect(page.locator("#terminal-panel").getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
})
function legacyTerminalKey() {
const dir = base64Encode("C:/OpenCode/TerminalInput")
const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal`
}
@@ -0,0 +1,227 @@
import { expect, test, type Page } from "@playwright/test"
import pkg from "../../package.json" with { type: "json" }
import { SERVER, type SeedInput } from "../utils/app"
import { openHeldStart } from "../utils/direction"
import { expectAppVisible } from "../utils/waits"
import { mockWorkspace } from "../utils/workspace"
// The web app's version, which What's New compares with the version last seen.
const current = pkg.version
const SEEN = "opencode.global.dat:extension.updater.seen"
const RELEASE_NOTES = "opencode.global.dat:extension.updater.releaseNotes"
const cors = { "access-control-allow-origin": "*" }
const splitPanes = {
title: "Split panes",
description: "Work side by side.",
media: { type: "image", src: "/split.png" },
}
// Newest first. Since 1.9.1 the app shows five desktop highlights: the CLI group, the repeat and the sixth stay out.
const changelog = {
releases: [
{
tag: `v${current}`,
highlights: [
{
source: "Desktop",
items: [
splitPanes,
{
title: "Voice input",
shortDescription: "Talk to the agent.",
media: { type: "VIDEO", url: "/voice.mp4" },
},
],
},
{ source: "cli", items: [{ title: "Terminal only", description: "Not in the desktop app." }] },
],
},
{
tag: "1.9.2",
highlights: [
{ source: "desktop", title: "Faster startup", description: "Opens in half the time." },
{
source: "desktop",
items: [
splitPanes,
{ title: "Themes", description: "Pick a theme." },
{ title: "Session tabs", description: "Keep sessions open." },
{ title: "Sixth", description: "Past the limit." },
],
},
],
},
{ tag: "1.9.1", highlights: [{ source: "desktop", title: "Seen before", description: "Shown at 1.9.1." }] },
],
}
// The list form, whose one release in range has no desktop highlights.
const cliOnly = [{ tag: current, highlights: [{ source: "cli", title: "Terminal only", description: "Not here." }] }]
// A user who last saw 1.9.1, stored where the app kept it before the updater extension owned What's New.
const upgraded = { storage: { "highlights.v1": { version: "1.9.1" } } }
test("What's New shows the highlights since the version seen, pages to Get started, and is seen once shown", async ({
page,
}) => {
const requests = await serveChangelog(page)
await start(page, upgraded)
const dialog = page.getByRole("dialog")
const title = dialog.getByRole("heading", { level: 1 })
const next = dialog.getByRole("button", { name: "Next", exact: true })
await expect(title).toHaveText("Split panes")
await expect(dialog.getByText("Work side by side.", { exact: true })).toBeVisible()
await expect(dialog.getByRole("img", { name: "Split panes", exact: true })).toHaveAttribute("src", "/split.png")
// Seen as soon as it shows, so however it closes, the next start shows nothing.
await expect.poll(() => stored(page, SEEN)).toEqual({ version: current })
await next.click()
await expect(title).toHaveText("Voice input")
await expect(dialog.getByText("Talk to the agent.", { exact: true })).toBeVisible()
await expect(dialog.locator("video")).toHaveAttribute("src", "/voice.mp4")
await page.keyboard.press("ArrowLeft")
await expect(title).toHaveText("Split panes")
await page.keyboard.press("ArrowRight")
await expect(title).toHaveText("Voice input")
await next.click()
await expect(title).toHaveText("Faster startup")
await expect(dialog.locator("img, video")).toHaveCount(0)
await next.click()
await expect(title).toHaveText("Themes")
await next.click()
await expect(title).toHaveText("Session tabs")
await expect(next).toHaveCount(0)
await dialog.getByRole("button", { name: "Get started", exact: true }).click()
await expect(dialog).toBeHidden()
expect(requests).toHaveLength(1)
})
test("Don't show these in the future turns release notes off and closes What's New", async ({ page }) => {
await serveChangelog(page)
await start(page, upgraded)
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
await dialog.getByRole("button", { name: "Don't show these in the future", exact: true }).click()
await expect(dialog).toBeHidden()
await expect.poll(() => stored(page, RELEASE_NOTES)).toEqual({ enabled: false })
})
test("a rejected update check shows one failure title without repeating it as a description", async ({ page }) => {
await mockWorkspace(page, { name: "UpdateCheck", sessions: [] })
await page.goto(
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "stopped", updater: "" })}`,
)
const settings = page.getByTestId("settings-screen")
const check = settings.getByRole("button", { name: "Check now", exact: true })
await expect(check).toBeEnabled()
await check.click()
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(page.getByText("Request failed", { exact: true })).toHaveCount(1)
await expect(page.getByText("You're up to date", { exact: true })).toHaveCount(0)
})
test("What's New opened after the attachment mounts waits for the routes, then keeps focus over restored Settings", async ({
page,
}) => {
const held = Promise.withResolvers<void>()
await serveChangelog(page, changelog, held.promise)
await mockWorkspace(page, { name: "Whats New", sessions: [], seed: upgraded })
const app = await openHeldStart(page, "/settings")
await app.start()
held.resolve()
// What's New marks the release seen as it opens: after attachment, while an extension still holds the routes.
await expect.poll(() => stored(page, SEEN)).toEqual({ version: "test" })
const dialog = page.getByRole("dialog")
const focused = () => dialog.evaluate((element) => element.contains(document.activeElement))
await expect(dialog).toHaveCount(0)
await expect(page.getByTestId("settings-screen")).toHaveCount(0)
await app.finish()
await expect(page.getByTestId("settings-screen")).toBeAttached()
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
await expect.poll(focused).toBe(true)
for (const key of ["Tab", "Tab", "Tab", "Tab", "Shift+Tab"]) {
await page.keyboard.press(key)
await expect.poll(focused).toBe(true)
}
})
test("a failed changelog request leaves What's New for the next start", async ({ page }) => {
const failed = Promise.withResolvers<void>()
const requests: string[] = []
await page.route("https://opencode.ai/changelog.json", async (route) => {
requests.push(route.request().url())
if (requests.length > 1) return route.fulfill({ headers: cors, json: changelog })
await route.fulfill({ status: 503, headers: cors })
failed.resolve()
})
await start(page, upgraded)
await failed.promise
expect(await stored(page, SEEN)).toEqual({ version: "1.9.1" })
await page.reload()
await expect(page.getByRole("dialog").getByRole("heading", { level: 1 })).toHaveText("Split panes")
})
// Each start ends with the current version seen and no dialog; only a release in range is worth a changelog request.
for (const row of [
{ name: "the first run only remembers the version", seed: {}, requests: 0 },
{
name: "a version seen before, stored as highlights.v1, shows nothing",
seed: { storage: { "highlights.v1": { version: current } } },
requests: 0,
},
{
name: "release notes turned off before, in settings.v3, only remember the version",
seed: { ...upgraded, settings: { general: { releaseNotes: false } } },
requests: 0,
},
{
name: "a changelog without desktop highlights since the version seen only remembers the version",
seed: upgraded,
changelog: cliOnly,
requests: 1,
},
]) {
test(`What's New: ${row.name}`, async ({ page }) => {
const requests = await serveChangelog(page, row.changelog)
await start(page, row.seed)
await expect.poll(() => stored(page, SEEN)).toEqual({ version: current })
expect(requests).toHaveLength(row.requests)
await expect(page.getByRole("dialog")).toHaveCount(0)
})
}
/** Serves this changelog and records each request for it. */
async function serveChangelog(page: Page, body: typeof changelog | typeof cliOnly = changelog, held?: Promise<void>) {
const requests: string[] = []
await page.route("https://opencode.ai/changelog.json", async (route) => {
requests.push(route.request().url())
await held
return route.fulfill({ headers: cors, json: body })
})
return requests
}
/** Opens Home over one empty project, with this storage, and waits until the window renders. */
async function start(page: Page, seed: SeedInput) {
await mockWorkspace(page, { name: "Whats New", sessions: [], seed })
await page.goto("/")
await expectAppVisible(page.getByRole("button", { name: "Settings", exact: true }))
}
function stored(page: Page, key: string) {
return page.evaluate((name) => localStorage.getItem(name), key).then((raw) => JSON.parse(raw ?? "null"))
}
@@ -1,28 +1,95 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { AppBaseProviders, AppInterface, preloadRoute } from "../../src/app"
import { useLanguage, type Direction } from "../../src/runtime/i18n/language"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { Command } from "@opencode/gui-extensions/sdk"
import { useExtensionHost } from "../../src/runtime/extension/host"
import { useCommand } from "../../src/shell/commands/command"
export function mount(input: { server: string; route: string; direction: Direction }) {
/**
* Mounts the app at a route. With `held`, the window's providers and extensions start at once and the app interface
* mounts only when "Start app" is pressed, over its preloaded route, as the desktop shell mounts it once its server is up.
* One extension's load also waits for "Finish startup", so the attachment can mount before the routes render.
*/
export function mount(input: { server: string; route: string; direction: Direction; held?: boolean }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: input.route, replace: true, scroll: false })
const server: ServerConnection.Http = { type: "http", http: { url: input.server } }
const [started, setStarted] = createSignal(!input.held)
const [finished, setFinished] = createSignal(false)
const held = Promise.withResolvers<void>()
function StartupGate() {
const host = useExtensionHost()
const command = useCommand()
// ExtensionCommands publishes before ready, proving the attachment mounted even while the routes wait.
const registered = () =>
host
.items(Command)
.some((item) => command.options.some((option) => option.id === `${item.extension}.${item.value.id}`))
return (
<button
disabled={!registered()}
onClick={() => {
held.resolve()
setFinished(true)
}}
>
Finish startup
</button>
)
}
function DirectedApp() {
const language = useLanguage()
language.setDirection(input.direction)
return (
<AppInterface
servers={[server]}
defaultServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
>
<Show when={input.held && !finished()}>
<StartupGate />
</Show>
</AppInterface>
)
}
function Startup() {
const host = useExtensionHost()
const definition = host.definitions().find((item) => item.renderer)
const load = definition?.renderer
// Hold a real extension through the public reload API before the startup gate first settles.
if (input.held && definition && load)
host.reload(definition.id, {
...definition,
renderer: async () => {
await held.promise
return load()
},
})
return (
<Show
when={started()}
fallback={<button onClick={() => void preloadRoute(input.route).then(() => setStarted(true))}>Start app</button>}
>
<DirectedApp />
</Show>
)
}
@@ -30,7 +97,7 @@ export function mount(input: { server: string; route: string; direction: Directi
() => (
<PlatformProvider value={createWebPlatform("test").platform}>
<AppBaseProviders locale="en">
<DirectedApp />
<Startup />
</AppBaseProviders>
</PlatformProvider>
),
+6 -1
View File
@@ -14,7 +14,12 @@
<script type="module">
import { mount } from "./app-direction.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), route: query.get("route"), direction: query.get("direction") })
mount({
server: query.get("server"),
route: query.get("route"),
direction: query.get("direction"),
held: query.has("held"),
})
</script>
</body>
</html>
+23 -1
View File
@@ -1,6 +1,28 @@
import type { Page } from "@playwright/test"
import { expect, type Page } from "@playwright/test"
import { SERVER } from "./app"
export async function openWithDirection(page: Page, route: string, direction: "ltr" | "rtl") {
await page.goto(`/e2e/utils/app-direction.html?${new URLSearchParams({ server: SERVER, route, direction })}`)
}
/**
* Opens the app at a route as the desktop shell starts it: the window's extensions run at once, and the app interface
* mounts over the preloaded route only when `start` runs, as it does once the desktop's server is up. One extension
* stays loading until `finish` runs; commands are published meanwhile, but the routes wait.
*/
export async function openHeldStart(page: Page, route: string) {
await page.goto(
`/e2e/utils/app-direction.html?${new URLSearchParams({ server: SERVER, route, direction: "ltr", held: "" })}`,
)
const start = page.getByRole("button", { name: "Start app", exact: true, includeHidden: true })
await expect(start).toBeVisible()
return {
// A modal open meanwhile hides the page outside it, so the button is found and clicked past the modal.
start: async () => {
await start.dispatchEvent("click")
await expect(page.getByRole("button", { name: "Finish startup", exact: true, includeHidden: true })).toBeEnabled()
},
finish: () => page.getByRole("button", { name: "Finish startup", exact: true, includeHidden: true }).dispatchEvent("click"),
}
}
+32 -12
View File
@@ -1,16 +1,31 @@
import { Schema, SchemaGetter } from "effect"
import { Predicate, Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { Pty } from "@opencode/schema/pty"
import { Worktree } from "@opencode/schema/worktree"
// Handlers answer plain fixture data: undefined properties are dropped, and other non-JSON values become null.
const Json = Schema.Json.pipe(
Schema.decodeTo(Schema.Unknown, {
decode: SchemaGetter.passthrough(),
encode: SchemaGetter.transform(jsonValue),
encode: SchemaGetter.transform(function json(value): Schema.Json {
if (value === null || Predicate.isString(value) || Predicate.isBoolean(value)) return value
if (Predicate.isNumber(value)) return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map(json)
if (!Predicate.isObject(value)) return null
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, json(item)]])),
)
}),
}),
HttpApiSchema.asJson(),
)
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
@@ -23,8 +38,11 @@ const Query = Schema.Struct({
type: Schema.optional(Schema.String),
mode: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const PtyParams = { ptyID: Pty.ID }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
@@ -45,10 +63,16 @@ export class MockShellNotFound extends Schema.TaggedError<MockShellNotFound>()("
message: Schema.String,
}) {}
// The server's error for a request without its password.
export class MockUnauthorized extends Schema.TaggedError<MockUnauthorized>()("UnauthorizedError", {
message: Schema.String,
}) {}
// A mutation the scenario did not configure a handler for.
export class MockUnsupported extends Schema.TaggedError<MockUnsupported>()("MockUnsupported", {
message: Schema.String,
}) {}
const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501))
const Group = HttpApiGroup.make("mock")
@@ -368,6 +392,12 @@ const Group = HttpApiGroup.make("mock")
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionWait", "/api/experimental/session/:sessionID/wait", {
params: SessionParams,
success: NoContent,
}),
@@ -409,13 +439,3 @@ const Group = HttpApiGroup.make("mock")
)
export const MockApi = HttpApi.make("mock").add(Group)
function jsonValue(value: unknown): Schema.Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map(jsonValue)
if (!value || typeof value !== "object") return null
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
)
}
File diff suppressed because it is too large. Load diff
+108 -52
View File
@@ -10,6 +10,12 @@ import { PlatformProvider, type Platform } from "../../src/runtime/platform/plat
import { ServerConnection } from "../../src/runtime/server/registry"
import { useExtensionServers } from "../../src/runtime/extension/servers"
/** The input of a WSL or SSH method this fixture answers. */
type FixtureInput = { id?: string; name?: string }
/** A main-side method: SSH `start` answers with the state revision, the others with nothing. */
type FixtureMethod = (input: FixtureInput) => number | void
// A desktop window. `wsl` is the Ubuntu server's endpoint (default `server`); updating OpenCode restarts it on `restart`
// (default `wsl`). `ssh` adds a saved SSH server `box`, ready on that endpoint with password `ssh-1`; each connect
// brings up a new remote server with the next password (`ssh-2`, ...). `storage=async` stores in localStorage behind
@@ -23,25 +29,32 @@ export function mount(input: {
ssh?: string | null
storage?: string | null
hold?: string | null
/** Enables the updater Ipc; check and install reject with this message, which may be empty. */
updater?: string | null
}) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: input.path ?? "/settings", replace: true, scroll: false })
const endpoint = { url: input.wsl ?? input.server }
const ready = () => ({ kind: "ready" as const, url: endpoint.url, password: null })
const held = Promise.withResolvers<void>()
const storage = (name?: string) => {
const item = (key: string) => (name ? `${name}:${key}` : key)
return {
getItem: async (key: string) => {
if (input.hold && key.includes(input.hold)) await held.promise
return localStorage.getItem(item(key))
},
setItem: async (key: string, value: string) => localStorage.setItem(item(key), value),
removeItem: async (key: string) => localStorage.removeItem(item(key)),
}
}
render(() => {
const [store, setStore] = createStore<{
calls: string[]
@@ -77,12 +90,13 @@ export function mount(input: {
servers: [
{
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
runtime:
input.mode === "ready"
? ready()
: input.mode === "failed"
? { kind: "failed", message: "WSL failed to start" }
: { kind: "stopped" },
runtime: (
{
ready: ready(),
failed: { kind: "failed", message: "WSL failed to start" },
stopped: { kind: "stopped" },
} satisfies Record<typeof input.mode, WslServerRuntime>
)[input.mode],
},
],
opencodeChecks: {
@@ -97,76 +111,96 @@ export function mount(input: {
},
},
})
// The main-process WSL and SSH extensions, as the extension bridge sees them.
const listeners = new Set<(message: BridgeMessage) => void>()
const snapshot = (remote: string) => structuredClone(unwrap(remote === "ssh" ? store.ssh : store.state))
const publish = (remote: string) =>
listeners.forEach((listener) => listener({ type: "state", remote, state: snapshot(remote) }))
const snapshot = (ipc: string) => structuredClone(unwrap(ipc === "ssh" ? store.ssh : store.state))
const publish = (ipc: string) =>
listeners.forEach((listener) => listener({ type: "state", ipc, state: snapshot(ipc) }))
// The contract state is deeply readonly, so each action replaces the changed branch.
const setRuntime = (id: string | undefined, runtime: WslServerRuntime) =>
setStore("state", (state) => ({
servers: state.servers.map((server) => (server.config.id === id ? { ...server, runtime } : server)),
}))
const setSsh = (item: Partial<SshItem>) =>
setStore("ssh", (state) => ({
revision: state.revision + 1,
servers: state.servers.map((server) => ({ ...server, ...item })),
}))
const methods: Record<string, Record<string, (input: { id?: string; name?: string }) => unknown>> = {
wsl: {
// Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint.
installOpencode(value) {
const name = value.name ?? ""
const id = store.state.servers.find((server) => server.config.distro === name)?.config.id
setStore("calls", (calls) => [...calls, `update:${value.name}`])
setRuntime(id, { kind: "stopped" })
publish("wsl")
setStore("state", (state) => ({
opencodeChecks: {
...state.opencodeChecks,
[name]: { ...state.opencodeChecks[name]!, version: "current", matchesDesktop: true },
},
}))
endpoint.url = input.restart ?? endpoint.url
setRuntime(id, ready())
},
startServer(value) {
setStore("calls", (calls) => [...calls, `start:${value.id}`])
setRuntime(value.id, ready())
},
removeServer(value) {
setStore("calls", (calls) => [...calls, `remove:${value.id}`])
setStore("state", (state) => ({ servers: state.servers.filter((server) => server.config.id !== value.id) }))
},
const wsl = {
// Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint.
installOpencode(value) {
const name = value.name ?? ""
const id = store.state.servers.find((server) => server.config.distro === name)?.config.id
setStore("calls", (calls) => [...calls, `update:${value.name}`])
setRuntime(id, { kind: "stopped" })
publish("wsl")
setStore("state", (state) => ({
opencodeChecks: {
...state.opencodeChecks,
[name]: { ...state.opencodeChecks[name]!, version: "current", matchesDesktop: true },
},
}))
endpoint.url = input.restart ?? endpoint.url
setRuntime(id, ready())
},
ssh: {
// Like main: a connect opens a new tunnel to a remote server with a new password.
start() {
setStore("connects", (count) => count + 1)
setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } })
return store.ssh.revision
},
startServer(value) {
setStore("calls", (calls) => [...calls, `start:${value.id}`])
setRuntime(value.id, ready())
},
}
removeServer(value) {
setStore("calls", (calls) => [...calls, `remove:${value.id}`])
setStore("state", (state) => ({ servers: state.servers.filter((server) => server.config.id !== value.id) }))
},
} satisfies Readonly<Record<string, FixtureMethod>>
const ssh = {
// Like main: a connect opens a new tunnel to a remote server with a new password.
start() {
setStore("connects", (count) => count + 1)
setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } })
return store.ssh.revision
},
} satisfies Readonly<Record<string, FixtureMethod>>
const methods = new Map<string, Readonly<Record<string, FixtureMethod>>>([
["wsl", wsl],
["ssh", ssh],
])
const bridge: Bridge = {
packaged: false,
async call(request) {
const method = methods[request.remote]?.[request.method]
if (request.ipc === "updater" && input.updater != null) throw new Error(input.updater)
const method = methods.get(request.ipc)?.[request.method]
if (!method) throw new Error("Unexpected fixture action")
const result = method(request.input as { id?: string; name?: string })
publish(request.remote)
// SAFETY: every WSL and SSH method the fixture answers takes a struct of these optional string fields.
const result = method(request.input as FixtureInput)
publish(request.ipc)
return result ?? null
},
async subscribe(remote) {
if (remote === "wsl" || remote === "ssh") return { available: true, state: snapshot(remote) }
async subscribe(ipc) {
if (ipc === "updater" && input.updater != null) return { available: true, state: { status: "idle" } }
if (ipc === "wsl" || ipc === "ssh") return { available: true, state: snapshot(ipc) }
return { available: false }
},
on(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
surface: () => undefined,
embed: () => undefined,
capture: async () => undefined,
menubar: () => undefined,
runMenubarItem: () => undefined,
configure: () => undefined,
manager: {
list: async () => [],
@@ -179,9 +213,11 @@ export function mount(input: {
asset: () => "",
},
}
const unused = async () => {
throw new Error("Unexpected fixture action")
}
const platform: Platform = {
platform: "desktop",
os: "windows",
@@ -191,14 +227,18 @@ export function mount(input: {
notify: async () => undefined,
restart: unused,
extensions: bridge,
...(input.storage === "async" ? { storage } : {}),
}
if (input.storage === "async") platform.storage = storage
function Interface() {
const extensions = useExtensionServers()
const servers = createMemo<ServerConnection.Any[]>(() => [
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
...extensions.list(),
])
return (
<Show when={extensions.ready()}>
<AppInterface
@@ -209,6 +249,7 @@ export function mount(input: {
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
@@ -221,12 +262,27 @@ export function mount(input: {
onChange={(event) => {
const available = event.currentTarget.checked
setStore("available", available)
listeners.forEach((listener) => listener({ type: "available", remote: "wsl", available }))
listeners.forEach((listener) => listener({ type: "available", ipc: "wsl", available }))
if (available) publish("wsl")
}}
/>
WSL extension
</label>
{/* The SSH extension's main side going away and coming back. */}
<label>
<input
type="checkbox"
checked
onChange={(event) => {
const available = event.currentTarget.checked
listeners.forEach((listener) => listener({ type: "available", ipc: "ssh", available }))
if (available) publish("ssh")
}}
/>
SSH extension
</label>
{/* The SSH tunnel dropping, which only main notices. */}
<Show when={input.ssh}>
<button
+1
View File
@@ -17,6 +17,7 @@
ssh: query.get("ssh"),
storage: query.get("storage"),
hold: query.get("hold"),
updater: query.get("updater"),
})
</script>
</body>
@@ -9,21 +9,25 @@ import { ServerConnection } from "../../src/runtime/server/registry"
// The app on a Windows desktop platform, so the titlebar shows the app menu. Menu actions are listed in an output.
export function mount(input: { server: string }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
render(() => {
const [store, setStore] = createStore({ actions: [] as string[] })
const [store, setStore] = createStore<{ actions: string[] }>({ actions: [] })
const unused = async () => {
throw new Error("Unexpected fixture action")
}
// No main-process extensions run in this fixture.
const bridge: Bridge = {
packaged: false,
call: unused,
subscribe: async () => ({ available: false }),
on: () => () => undefined,
surface: () => undefined,
embed: () => undefined,
capture: async () => undefined,
menubar: () => undefined,
runMenubarItem: () => undefined,
configure: () => undefined,
manager: {
list: async () => [],
@@ -36,6 +40,7 @@ export function mount(input: { server: string }) {
asset: () => "",
},
}
const platform: Platform = {
platform: "desktop",
os: "windows",
@@ -47,6 +52,7 @@ export function mount(input: { server: string }) {
extensions: bridge,
runDesktopMenuAction: (action) => setStore("actions", (actions) => [...actions, action]),
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
+4 -5
View File
@@ -11,7 +11,6 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
import { GlobalProvider } from "@/runtime/server/runtime"
import { HighlightsProvider } from "@/shell/updates/highlights"
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
@@ -32,6 +31,7 @@ function QueryProvider(props: ParentProps) {
},
},
})
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
@@ -65,6 +65,7 @@ export function AppBaseProviders(
<ErrorBoundary
fallback={(error) => {
void import("@sentry/solid").then(({ captureException }) => captureException(error))
return <ErrorPage error={error} />
}}
>
@@ -99,10 +100,8 @@ export function AppInterface(props: {
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
{props.children}
{rootProps.children}
</CommandProvider>
</GlobalProvider>
</TabsProvider>
+109 -23
View File
@@ -31,11 +31,13 @@ import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
import type {
ComposerAgentPart,
ComposerAttachment,
ComposerComment,
ComposerFilePart,
ComposerOption,
ComposerPersistedState,
ComposerPrompt,
ComposerSkillPart,
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
@@ -52,11 +54,17 @@ export type {
} from "../types"
export type ComposerMode = "normal" | "shell"
const COMPOSER_SUGGESTION_MAX_HEIGHT = 320
const COMPOSER_SUGGESTION_ROW_HEIGHT = 28
const COMPOSER_SUGGESTION_ROW_PEEK = 18
const COMPOSER_SUGGESTION_TOP_PADDING = 8
const COMPOSER_SUGGESTION_SEARCH_HEIGHT = 28
const COMPOSER_SUGGESTION_CONTEXT_RESERVE = 80
export type ComposerEditorProps = {
@@ -83,6 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
let controlsViewport!: HTMLDivElement
let controlsContent!: HTMLDivElement
const [overflow, setOverflow] = createStore({ start: false, end: false })
const updateOverflow = () => {
const offset = Math.abs(controlsViewport.scrollLeft)
setOverflow({
@@ -90,6 +99,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
})
}
onMount(() => {
const observer = new ResizeObserver(updateOverflow)
observer.observe(controlsViewport)
@@ -98,11 +108,14 @@ export function ComposerEditor(props: ComposerEditorProps) {
onCleanup(() => observer.disconnect())
})
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
props.controller.onCursor(composerCursor(editor))
}
const mode = createMemo(() => state.mode)
const buttons = createMemo(() => ({
opacity: mode() === "normal" ? 1 : 0,
"pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const),
@@ -111,14 +124,31 @@ export function ComposerEditor(props: ComposerEditorProps) {
createEffect(() => {
const parts = props.controller.parts()
if (!editor) return
if (localInput) {
localInput = false
return
}
renderComposerEditor(editor, parts)
})
// Kept out of JSX: an inline ternary prop compiles to a memo created on every read, and the popover reads
// search from a ResizeObserver callback, where that memo has no owner and is never disposed.
const search = () =>
state.popover.type === "command-menu"
? {
value: state.popover.query,
label: i18n.t("ui.promptInput.commands"),
placeholder: "/",
onValueChange: props.controller.setQuery,
onKeyDown: props.controller.onKeyDown,
}
: undefined
return (
<div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
<input
@@ -128,6 +158,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
class="hidden"
onChange={(event) => {
const list = event.currentTarget.files
if (list) props.controller.addAttachments(Array.from(list))
event.currentTarget.value = ""
}}
@@ -138,17 +169,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
? {
value: state.popover.query,
label: i18n.t("ui.promptInput.commands"),
placeholder: "/",
onValueChange: props.controller.setQuery,
onKeyDown: props.controller.onKeyDown,
}
: undefined
}
search={search()}
onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
/>
@@ -162,6 +183,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onSubmit={(event) => {
event.preventDefault()
if (!props.disabled) props.controller.submit()
}}
onDragEnter={props.controller.onDragEnter}
@@ -224,12 +246,16 @@ export function ComposerEditor(props: ComposerEditorProps) {
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit(mod ? { alternate: true } : undefined)
}
@@ -241,12 +267,17 @@ export function ComposerEditor(props: ComposerEditorProps) {
// Programmatic multiline insertion does not reliably reveal the caret.
requestAnimationFrame(() => {
const selection = window.getSelection()
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
if (!editor.contains(selection.anchorNode)) return
const caret = selection.getRangeAt(0).getBoundingClientRect()
if (!caret.height) return
const bounds = viewport.getBoundingClientRect()
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
})
}}
@@ -375,6 +406,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (isAttachment(part)) return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
@@ -384,19 +416,26 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
mention.style.unicodeBidi = "isolate"
mention.dataset.mention =
part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
if (part.type === "agent") mention.dataset.name = part.name
if (part.type === "skill") {
mention.dataset.id = part.id
mention.dataset.name = part.name
}
if (part.type === "file") {
mention.dataset.path = part.path
if (part.mime) mention.dataset.mime = part.mime
if (part.filename) mention.dataset.filename = part.filename
}
return [mention]
}),
)
if (!active) return
const selection = window.getSelection()
const range = document.createRange()
@@ -417,85 +456,111 @@ function parseComposerEditor(editor: HTMLDivElement) {
position += buffer.length
buffer = ""
}
const mention = (element: HTMLElement) => {
flush()
const content = element.textContent ?? ""
const original = mentionParts.get(element)
if (element.dataset.mention === "agent") {
parts.push({
...(original?.type === "agent" ? original : {}),
const agent: ComposerAgentPart = {
type: "agent",
name: element.dataset.name ?? content.slice(1),
content,
start: position,
end: position + content.length,
})
}
parts.push(original?.type === "agent" ? { ...original, ...agent } : agent)
position += content.length
return
}
if (element.dataset.mention === "skill") {
parts.push({
...(original?.type === "skill" ? original : {}),
const skill: ComposerSkillPart = {
type: "skill",
id: Skill.ID.make(element.dataset.id ?? content.slice(1)),
name: Skill.Name.make(element.dataset.name ?? content.slice(1)),
content,
start: position,
end: position + content.length,
})
}
parts.push(original?.type === "skill" ? { ...original, ...skill } : skill)
position += content.length
return
}
parts.push({
...(original?.type === "file" ? original : {}),
const parsed: ComposerFilePart = {
type: "file",
path: element.dataset.path ?? content.slice(1),
content,
start: position,
end: position + content.length,
...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
})
}
const file: ComposerFilePart = original?.type === "file" ? { ...original, ...parsed } : parsed
if (element.dataset.mime) file.mime = element.dataset.mime
if (element.dataset.filename) file.filename = element.dataset.filename
parts.push(file)
position += content.length
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
return
}
if (!(node instanceof HTMLElement)) return
if (node.dataset.mention) {
mention(node)
return
}
if (node.tagName === "BR") {
buffer += "\n"
return
}
Array.from(node.childNodes).forEach(visit)
}
Array.from(editor.childNodes).forEach((node, index, nodes) => {
visit(node)
if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
})
flush()
if (
parts.every((part) => part.type === "text") &&
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
) {
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
}
if (parts.length > 0) return parts
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
}
function composerCursor(editor: HTMLDivElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(editor)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}
@@ -513,6 +578,7 @@ export function ComposerAttachments(props: {
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
@@ -570,6 +636,7 @@ export function ComposerAttachments(props: {
{(image) => {
// Restored drafts and history carry image ids only; bytes load when shown.
const [url] = createResource(() => image().blob, resolveBlobUrl)
return (
<>
{/* Keep loading local; the route boundary would detach the screen and drop editor focus. */}
@@ -703,6 +770,7 @@ function ComposerEditorConfiguredSelect(props: {
}) {
const current = () => props.control.current()
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
return (
<ComposerEditorSelect
title={props.title}
@@ -790,13 +858,17 @@ export function ComposerEditorPopover(props: {
onSelect: (item: ComposerSuggestion) => void
}) {
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
const resize = (height: number) =>
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
// A detached boundary (e.g. the previous session's timeline while the next one loads) measures 0px.
const boundary = () => {
const element = props.boundary?.()
return element?.isConnected ? element : undefined
}
createEffect(() => resize(boundary()?.clientHeight ?? COMPOSER_SUGGESTION_MAX_HEIGHT * 2))
createResizeObserver(boundary, (rect) => resize(rect.height))
@@ -862,7 +934,9 @@ function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
return (
chrome +
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
@@ -878,14 +952,20 @@ function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
const i18n = useI18n()
const view = props.controller.view
const action = createMemo(() => {
const queue = view.submit.queue
if (!queue || !props.controller.canSubmit()) return undefined
if (queue.editing()) return "steer" as const
return queue.alternate()
})
const [button, setButton] = createSignal<HTMLButtonElement>()
const presence = createAnimatedPresence(action, () => button() ?? null)
return (
<Show when={presence.present() && presence.value()} keyed>
{(delivery) => (
@@ -941,10 +1021,13 @@ export function ComposerEditorSubmitButton(props: {
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (props.stopping) {
props.onStop()
return
}
props.onSubmit()
}}
/>
@@ -954,8 +1037,11 @@ export function ComposerEditorSubmitButton(props: {
function ComposerSuggestionIcon(props: { item: ComposerSuggestion }) {
if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
if (props.item.kind === "skill") return <Icon name="post-skill" size="small" class="shrink-0" />
if (props.item.kind === "command") return null
return (
<FileIcon
node={{ path: props.item.path ?? props.item.label, type: props.item.kind === "reference" ? "directory" : "file" }}
+75 -18
View File
@@ -1,8 +1,9 @@
import { ImagePreview } from "@opencode/ui/image-preview"
import { useDialog } from "@opencode/ui/context/dialog"
import type { ReferenceInfo } from "@opencode/client/promise"
import type { Links, SessionView } from "@opencode/gui-extensions/sdk"
import type { Links, MountedSession } from "@opencode/gui-extensions/sdk"
import { createComponent, createEffect, createMemo, on } from "solid-js"
import { Predicate } from "effect"
import type { ComposerSuggestion } from "./types"
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspaces/files/model"
@@ -10,7 +11,7 @@ import { useComments } from "@/composer/comments"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useExtensionHost } from "@/runtime/extension/host"
import { useExtensionAttachment } from "@/runtime/extension/services"
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
@@ -30,12 +31,20 @@ export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
}
const sendFailedTitle = {
shell: "prompt.toast.shellSendFailed.title",
command: "prompt.toast.commandSendFailed.title",
prompt: "prompt.toast.promptSendFailed.title",
} as const
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const server = useServer()
const available = () =>
server.conn.type !== "extension" || !server.conn.managed || server.ctx.sdk.connection.status() === "connected"
const files = useFile()
const links = useExtensionHost().links
const extensions = useExtensionAttachment()
@@ -60,43 +69,59 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
)
const mode = () => interaction[0].mode
const history = createComposerHistory()
const recent = createMemo(() => {
const all = extensions.files.opened()
const active = extensions.files.active()
return active ? [active, ...all.filter((path) => path !== active)] : all
})
const attachments = createMemo(() => prompt.current().filter(isAttachment))
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => adapter.working() && blank())
const placeholder = () => {
if (mode() === "shell") return language.t("prompt.placeholder.shell", { example: "git status" })
if (adapter.working() || (options?.queue?.count() ?? 0) > 0)
return language.t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
return language.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment || item.type !== "file") return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
@@ -110,6 +135,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
]
})
}
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
@@ -137,6 +163,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
(data.location.reference.list({ directory: sdk().directory }) ?? [])
.filter((reference) => !reference.hidden)
@@ -157,7 +184,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
})),
)
const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? [])
const context = createMemo<ComposerSuggestion[]>(() => [
...references(),
...skills().map((skill) => ({
@@ -192,6 +221,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
})),
])
const slashCommands = createMemo(() => [
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
id: `custom.${item.name}`,
@@ -211,6 +241,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
@@ -222,7 +253,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
keybind: command.keybindParts(item.id),
})),
])
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
@@ -236,7 +269,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
closePopover: () => controller.dispatch({ type: "popover.close" }),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
},
notify: {
@@ -247,13 +282,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}),
failed: (kind, error) =>
showToast({
title: language.t(
kind === "shell"
? "prompt.toast.shellSendFailed.title"
: kind === "command"
? "prompt.toast.commandSendFailed.title"
: "prompt.toast.promptSendFailed.title",
),
title: language.t(sendFailedTitle[kind]),
description:
kind === "command"
? formatServerError(error, language.t, language.t("common.requestFailed"))
@@ -267,10 +296,13 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
clientCommand: (text) => {
const selected = parseClientSlashCommand(slashCommands(), text)
if (!selected) return
return () => command.trigger(selected.id, "slash", selected.input)
},
})
const controller = createComposerEditor({
store: prompt.store,
state: interaction,
@@ -278,6 +310,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
// SAFETY: `entries` stores each entry's comments as its metadata, and the editor restores only metadata it read there.
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
},
commands,
@@ -301,23 +334,32 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
openContext(key) {
const item = controller.contextItem(key)
if (item?.type === "note") {
// The extension that attached the note reveals its subject.
const href = item.live?.href ?? item.href
if (href) links.open({ href, origin: item.origin, session: extensions.current() })
return
}
if (item) openComment(item, links, extensions.current(), files, comments)
},
onEditor(element) {
// SAFETY: the editor's only `setEditor` call passes its editable root, a `<div>` (`editor/editor.tsx`).
editor = element as HTMLDivElement
if (adapter.kind === "active-session") adapter.setEditor(editor)
},
onSuggestionSelect(item) {
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
if (selected.arguments) return
return () => command.trigger(selected.id, "slash")
},
attachments: {
@@ -347,6 +389,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
placeholder,
get agent() {
const agents = adapter.controls().agents
return agents.visible && agents.options.length > 0
? {
options: () => adapter.controls().agents.options.map((name) => ({ id: name, label: name })),
@@ -370,20 +413,25 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
if (queue?.undoing()) return
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
void submission.submit(new Event("submit"), submitOptions)
},
onStop: () => void submission.stop(),
},
},
})
Object.defineProperty(controller, "model", { get: () => adapter.controls().model })
command.register("composer-editor", () => [
@@ -414,42 +462,51 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
])
// SAFETY: the `model` getter defined on the controller above completes `ComposerModel`.
return controller as ComposerModel
}
function composerErrorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
return error.message
}
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: { message?: string } }).data
if (data?.message) return data.message
}
function composerErrorMessage(language: ReturnType<typeof useLanguage>, cause: unknown) {
if (Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)) return cause.message
if (
Predicate.hasProperty(cause, "data") &&
Predicate.hasProperty(cause.data, "message") &&
Predicate.isString(cause.data.message) &&
cause.data.message
)
return cause.data.message
return language.t("common.requestFailed")
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
links: Links,
session: SessionView | undefined,
session: MountedSession | undefined,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
) {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
const queueFocus = (attempts = 6) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (attempts <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
})
})
}
// The extension that owns the comment's origin reveals it (the review diff or a file tab), keeping the conversation.
links.open({ href: item.path, origin: item.commentOrigin, exact: true, background: true, session })
if (item.commentOrigin === "review") return queueFocus()
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
}
@@ -35,7 +35,6 @@ import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
import type { ModelSelection } from "@/providers/models/selection"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { useTabs } from "@/shell/tabs/tabs"
@@ -0,0 +1,15 @@
import { createContext, useContext } from "solid-js"
import type { createExtensionAttachment } from "./host-apis"
// Apart from `host-apis.tsx`, so session code such as the side panels reads the attachment without loading the HostApis.
const AttachmentContext = createContext<ReturnType<typeof createExtensionAttachment>>()
export function useExtensionAttachment() {
const value = useContext(AttachmentContext)
if (!value) throw new Error("Extension attachment is unavailable")
return value
}
export const ExtensionAttachmentProvider = AttachmentContext.Provider
@@ -2,7 +2,7 @@ import { createEffect, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import type { SurfaceProps, Surfaces } from "@opencode/gui-extensions/sdk"
import type { EmbedProps, Embeds } from "@opencode/gui-extensions/sdk"
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
const geometry =
@@ -14,56 +14,65 @@ type Input = {
readonly dialog: () => boolean
}
/** Native main-process surfaces laid out over the DOM box an extension renders for them. */
export function createSurfaces(input: Input): Surfaces {
/** Main-process embeds laid out over the DOM box an extension renders for them. */
export function createEmbeds(input: Input): Embeds {
return {
View: (props) => {
const bridge = input.bridge
// Without the desktop bridge there is no surface; the box still renders its children.
// Without the desktop bridge there is no embed; the box still renders its children.
if (!bridge) return <div class={props.class}>{props.children}</div>
return <SurfaceView {...props} input={input} bridge={bridge} />
return <EmbedView {...props} input={input} bridge={bridge} />
},
capture: (id) => input.bridge?.capture(id) ?? Promise.resolve(undefined),
}
}
function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
const [store, setStore] = createStore({
function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) {
const [store, setStore] = createStore<{ visible: boolean; snapshot: { id: string; url: string } | undefined }>({
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the surface shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { id: string; url: string } | undefined,
// A still of the embed shown in the DOM while floating content covers the hidden native view.
snapshot: undefined,
})
let element: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
let capturing: string | undefined
let release: ReturnType<typeof setTimeout> | undefined
// The surface the last layout went to; it hides when another one takes the box.
// The embed the last layout went to; it hides when another one takes the box.
let placed: string | undefined
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
const hide = () => {
if (placed) props.bridge.surface(placed)
if (placed) props.bridge.embed(placed)
placed = undefined
}
// The native surface always paints above the DOM, so hide it while a floating
// The native embed always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const replaceSnapshot = (next?: { id: string; url: string }) => {
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
setStore("snapshot", next)
}
// Keep the surface on screen as a still under the floating content. The native view
// Keep the embed on screen as a still under the floating content. The native view
// stays visible until the still has decoded, so the box never flashes blank.
const freeze = (id: string) => {
clearTimeout(release)
release = undefined
if (store.snapshot?.id === id || capturing === id) return
capturing = id
void props.bridge
@@ -71,23 +80,29 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
.catch(() => undefined)
.then(async (data) => {
const url = data ? URL.createObjectURL(new Blob([new Uint8Array(data)], { type: "image/jpeg" })) : ""
if (url) {
const image = new Image()
image.src = url
await image.decode().catch(() => undefined)
}
if (capturing !== id) {
if (url) URL.revokeObjectURL(url)
return
}
capturing = undefined
// A failed capture still hides the surface; the box shows its background as before.
// A failed capture still hides the embed; the box shows its background as before.
replaceSnapshot({ id, url })
schedule()
})
}
const thaw = () => {
capturing = undefined
if (!store.snapshot || release !== undefined) return
// Keep the still under the native view until the view has painted again.
release = setTimeout(() => {
@@ -95,9 +110,11 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
replaceSnapshot()
}, 150)
}
const measure = () => {
if (!element) return
const id = props.id
if (!id) return hide()
const rect = element.getBoundingClientRect()
const zoom = props.input.zoom()
@@ -107,16 +124,22 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
const bottom = Math.round(rect.bottom * zoom)
const shown = props.visible && store.visible && !props.input.dialog()
const cover = !!props.frozen || covered(rect)
if (shown && cover) freeze(id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.id === id)
// The cutout exposes the app backdrop outside the rounded card, not the surface inside it.
// The cutout exposes the app backdrop outside the rounded card, not the embed inside it.
const color =
props.background ??
getComputedStyle(element.closest(".bg-v2-background-bg-deep") ?? document.documentElement).backgroundColor
const next = `${id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
if (next === layout) return
layout = next
// Let the browser resolve the color, including custom themes using color formats
// that Electron's color parser cannot read.
if (paint) {
@@ -124,23 +147,31 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
if (placed !== id) hide()
placed = id
props.bridge.surface(id, {
const box = {
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
...(rgba ? { background: [rgba[0], rgba[1], rgba[2], rgba[3]] as const } : {}),
radius: Math.round((props.radius ?? 0) * zoom),
})
}
props.bridge.embed(id, rgba ? { ...box, background: [rgba[0], rgba[1], rgba[2], rgba[3]] as const } : box)
}
const tick = () => {
frame = undefined
measure()
if (performance.now() < until) frame = requestAnimationFrame(tick)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(tick)
}
@@ -156,15 +187,16 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
],
() => {
layout = undefined
// Native views are not clipped by the retained panel's DOM. Hide before the next
// animation frame so closing the panel cannot leave the surface above the app.
// The native views are not clipped by the retained panel's DOM. Hide before the next
// animation frame so closing the panel cannot leave the embed above the app.
if (!props.visible || !store.visible || props.input.dialog() || !props.id) return hide()
schedule(300)
},
),
)
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
// animation frame keeps the native view in step with a pane drag.
// animation frame keeps the native view in step with a region drag.
createResizeObserver(() => element, measure)
createEventListener(window, "resize", () => schedule(300))
// A layout transition elsewhere, such as the chat column's width while the side pane opens, can
@@ -0,0 +1,882 @@
import {
batch,
createComputed,
createEffect,
createMemo,
createSignal,
getOwner,
on,
runWithOwner,
untrack,
type Accessor,
} from "solid-js"
import { createStore, produce, type Store } from "solid-js/store"
import type { Schema } from "effect"
import { useDialog } from "@opencode/ui/context/dialog"
import { base64Encode } from "@opencode/util/encode"
import {
Panel,
type Build,
type Layout,
type MountedSession,
type OpenOptions,
type PanelSidebar,
type PanelState,
type ServerRef,
type SessionRef,
type SessionScreen,
type Storage,
type StorageScope,
type StoreFrom,
type StoreOptions,
} from "@opencode/gui-extensions/sdk"
import { usePlatform } from "@/runtime/platform/platform"
import { same } from "@/runtime/persistence/equality"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { useGlobal, type ServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { SessionRouteKey, SessionStateKey, type ServerScope } from "@/runtime/server/scope"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { terminalFontFamily, useSettings } from "@/settings/model"
import { useSettingsSurface } from "@/settings/surface"
import { formatKeybindParts, useCommand } from "@/shell/commands/command"
import { createMediaQuery } from "@solid-primitives/media"
import { useIsRouting, useLocation } from "@solidjs/router"
import { useLanguage } from "@/runtime/i18n/language"
import { createEmbeds } from "./embeds"
import { useExtensionHost, type HostApiFactories } from "./host"
import { createLocatedWrites } from "./located"
import type { Region } from "./panels"
import { deferredHandle, globalStoreTarget, persistedHandle, storeImports, storeName } from "./stores"
type Attached = {
sessions: Accessor<readonly SessionRef[]>
current: Accessor<MountedSession | undefined>
screen: Accessor<SessionScreen | undefined>
scope: (server: string) => ServerScope
/** Records a session-scoped store so layout pruning drops it with the session. */
scoped: (name: string) => void
layout: Omit<Layout, "narrow" | "settings" | "project" | "stored"> & {
stored(extension: string, session: SessionRef): readonly string[]
}
settings: (page?: string) => void
project: (server: string, title: string) => void
font: Accessor<string>
routing: Accessor<boolean>
path: Accessor<string>
keybind: (command: string) => readonly string[]
matches: (command: string, event: KeyboardEvent) => boolean
servers: Accessor<readonly string[]>
}
/**
* HostApis the host owns. Session and layout attach once the app interface mounts; until then their reads return
* their documented defaults and their writes wait, then apply in call order.
*/
export function createHostApis() {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
// The session screen's breakpoint, negated, so no fractional width is both narrow and desktop.
const desktop = createMediaQuery("(min-width: 768px)")
const narrow = () => !desktop()
const [attached, setAttached] = createSignal<Attached>()
const removed = new Set<(value: { server: string; directory: string }) => void>()
const memory = new Map<string, ReturnType<Storage["memory"]>>()
const current = () => attached()
// Writes made while the app interface is not mounted, in call order.
const early: ((value: Attached) => void)[] = []
// Removals of a session's store wait for its location, as layout writes do.
const located = createLocatedWrites()
// Runs a write now when the interface is mounted and nothing waits before it; otherwise once it mounts.
const write = (run: (value: Attached) => void) => {
const value = untrack(attached)
if (value && early.length === 0) return run(value)
early.push(run)
}
// Promise.try runs each write synchronously and isolates a throw, such as an unlisted server, from the later ones.
const flush = () => {
const value = untrack(attached)
if (!value) return
batch(() =>
early
.splice(0)
.forEach(
(run) => void Promise.try(() => run(value)).catch((cause: unknown) => console.error("[extension]", cause)),
),
)
}
const embeds = createEmbeds({
bridge: platform.extensions,
zoom: () => platform.webviewZoom?.() ?? 1,
dialog: () => !!dialog.active,
})
// A server or session store's key needs the server's scope, which the mounted interface provides.
const target = (
extension: string,
key: string,
scope: Exclude<StorageScope, "global">,
from: StoreFrom | readonly StoreFrom[] | undefined,
connected: Attached,
) => {
const name = storeName(extension, key)
if ("session" in scope) {
const location = scope.session.location
if (!location) throw new Error("Session storage requires a session location")
connected.scoped(name)
const server = connected.scope(scope.session.server.id)
const directory = base64Encode(location.directory)
const session = SessionStateKey.from(server, SessionRouteKey.fromRoute(directory, scope.session.id))
return {
...Persist.serverSession(server, directory, scope.session.id, name),
copyFrom: storeImports(from, session),
}
}
const copyFrom = storeImports(from)
if (!scope.directory) return { ...Persist.serverGlobal(connected.scope(scope.server), name), copyFrom }
return { ...Persist.serverWorkspace(connected.scope(scope.server), base64Encode(scope.directory), name), copyFrom }
}
const apis: HostApiFactories = {
storage: (extension, owner) => ({
store<S extends Schema.ConstraintCodec<object, unknown>>(key: string, options: StoreOptions<S>) {
const open = (at: Parameters<typeof persisted>[0]) => {
const pair = persisted(at, options.schema, options.initial, platform)
return persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise })
}
const scope = options.scope
// Persistence owns effects and resources; code after an await in setup has no owner.
return runWithOwner(getOwner() ?? owner, () => {
if (!scope || scope === "global") return open(globalStoreTarget(extension, key, options.from))
const scoped = (connected: Attached | undefined) =>
connected && open(target(extension, key, scope, options.from, connected))
// A server store opens once the interface mounts. A session store also waits for the session's location, and
// opens again in a new directory; the store a run opened disposes with the next run.
if (!("session" in scope)) return deferredHandle(createMemo(on(attached, scoped)))
const directory = createMemo(() => scope.session.location?.directory)
return deferredHandle(
createMemo(
on([attached, directory], ([connected, value]) => (value === undefined ? undefined : scoped(connected))),
),
)
})!
},
memory<T extends object>(key: string, options: { readonly initial: T }) {
const name = `${extension}.${key}`
const existing = memory.get(name)
if (existing) {
// SAFETY: a memory key is one extension's store, which that extension always opens with the same shape.
return existing as readonly [Store<T>, (mutation: (draft: T) => void) => void]
}
const [store, setStore] = createStore(options.initial)
const value = [store, (mutation: (draft: T) => void) => setStore(produce(mutation))] as const
memory.set(name, value)
return value
},
remove(key, options) {
const scope = options?.scope
if (!scope || scope === "global")
return removePersisted(globalStoreTarget(extension, key, options?.from), platform)
const run = (connected: Attached) =>
removePersisted(target(extension, key, scope, options?.from, connected), platform)
// A session's store is found through its location: the removal waits for it, as layout writes do.
write((connected) =>
"session" in scope && !scope.session.location
? located.hold(scope.session, () => write(run))
: run(connected),
)
},
}),
system: () => ({
copy: (text) => platform.writeClipboardText?.(text) ?? navigator.clipboard.writeText(text),
async save(file) {
if (platform.saveFile) return platform.saveFile({ defaultPath: file.name }, file.content)
const url = URL.createObjectURL(new Blob([file.content], { type: "application/octet-stream" }))
const link = document.createElement("a")
link.href = url
link.download = file.name
link.click()
URL.revokeObjectURL(url)
return true
},
openExternal(url) {
if (platform.openLocalFile && URL.canParse(url) && new URL(url).protocol === "file:")
return platform.openLocalFile(url)
platform.openExternal(url)
},
}),
desktop: () =>
platform.platform === "desktop"
? {
os: platform.os ?? "linux",
window: platform.windowID,
zoom: () => platform.webviewZoom?.() ?? 1,
launch: (path, app) => platform.openPath?.(path, app) ?? Promise.resolve(),
reveal: (path) => platform.revealPath?.(path) ?? Promise.resolve(false),
installed: (app) => platform.checkAppExists?.(app) ?? Promise.resolve(false),
forceFocus: (enabled) => platform.setForceFocus?.(enabled) ?? Promise.resolve(),
}
: undefined,
build: () => ({
version: platform.version ?? "",
// SAFETY: the build sets VITE_OPENCODE_CHANNEL to one of the release channels, or leaves it unset locally.
channel: (import.meta.env.VITE_OPENCODE_CHANNEL ?? "local") as Build["channel"],
platform: platform.platform,
packaged: platform.extensions?.packaged ?? false,
}),
locale: () => ({
locale: language.intl,
direction: language.direction,
setDirection: language.setDirection,
}),
// Before the interface mounts, the default mono font.
appearance: () => ({ font: () => current()?.font() ?? terminalFontFamily(undefined) }),
router: () => ({
routing: () => current()?.routing() ?? false,
path: () => current()?.path() ?? "",
}),
keybinds: () => ({
keybind: (command) => current()?.keybind(command) ?? [],
keys: (bind) => formatKeybindParts(bind, language.t),
matches: (command, event) => current()?.matches(command, event) ?? false,
}),
servers: () => ({ list: () => current()?.servers() ?? [] }),
workspaces: (_extension, _owner, _context, register) => ({
on(_event, handler) {
removed.add(handler)
return register(() => {
removed.delete(handler)
})
},
}),
sessions: () => ({
list: () => current()?.sessions() ?? [],
current: () => current()?.current(),
}),
screen: () => ({
current: () => {
const attached = current()
return attached?.current() ? attached.screen() : undefined
},
}),
// Before the interface mounts, reads return what an empty layout holds and writes wait for it.
layout: (extension) => ({
narrow,
ready: () => current()?.layout.ready() ?? false,
open: (key, session, options) => write((value) => value.layout.open(key, session, options)),
close: (key, session) => write((value) => value.layout.close(key, session)),
toggle: (key, session) => write((value) => value.layout.toggle(key, session)),
state: (key, session) => current()?.layout.state(key, session) ?? "closed",
stored: (session) => current()?.layout.stored(extension, session) ?? [],
side: {
opened: (session) => current()?.layout.side.opened(session) ?? false,
toggle: (session) => write((value) => value.layout.side.toggle(session)),
},
sidebar: { opened: () => current()?.layout.sidebar.opened() ?? true },
dock: {
opened: (session) => current()?.layout.dock.opened(session) ?? false,
placement: () => current()?.layout.dock.placement() ?? "side",
},
scroll: {
get: (session, key) => current()?.layout.scroll.get(session, key),
set: (session, key, value) => write((connected) => connected.layout.scroll.set(session, key, value)),
},
settings: (page) => write((value) => value.settings(page)),
project: (server, title) => write((value) => value.project(server, title)),
}),
embeds: () => embeds,
}
return {
apis,
/**
* Runs `run` now while the app interface is mounted and no write waits; otherwise after the interface's first
* render, behind the writes made before it. The returned function cancels a run that still waits.
*/
whenMounted(run: () => void) {
const state = { cancelled: false }
write(() => {
if (!state.cancelled) run()
})
return () => {
state.cancelled = true
}
},
attach(value: Attached) {
setAttached(() => value)
// After the interface's first render, so the waiting writes find its layout, in call order.
queueMicrotask(flush)
return () => {
if (attached() === value) setAttached(undefined)
}
},
workspaceRemoved(value: { server: string; directory: string }) {
removed.forEach((handler) => handler(value))
},
}
}
export type HostApis = ReturnType<typeof createHostApis>
export { ExtensionAttachmentProvider, useExtensionAttachment } from "./attachment"
/** Builds the session and layout attachment before readiness; its `attach` connects HostApis as the routes render. */
export function createExtensionAttachment(apis: HostApis) {
const global = useGlobal()
const tabs = useTabs()
const layout = useLayout()
const route = useCurrentRoute()
const settings = useSettings()
const surface = useSettingsSurface()
const host = useExtensionHost()
const command = useCommand()
const location = useLocation()
const desktop = createMediaQuery("(min-width: 768px)")
const narrow = () => !desktop()
// The mounted session screen, from its first render, and its object for the session it routes once it has mounted;
// one screen is mounted at a time.
const [screen, setScreen] = createSignal<SessionScreen>()
const [routedView, setRoutedView] = createSignal<Accessor<MountedSession>>()
const refs = new Map<string, SessionRef>()
const connection = (id: string) => global.servers.list().find((item) => ServerConnection.key(item) === id)
const servers = useServers()
// One ref per server id. A restarted server (e.g. an updated WSL server) gets a new controller under the same id,
// so the ref follows the live controller instead of the one it was created with.
const owner = getOwner()
const serverRefs = new Map<string, ServerRef>()
const server = (id: string): ServerRef | undefined => {
const conn = connection(id)
if (!conn) return
const existing = serverRefs.get(id)
if (existing) return existing
const key = ServerConnection.Key.make(id)
const live = runWithOwner(owner, () =>
createMemo<ServerCtx>((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)),
)!
const ref: ServerRef = {
id,
get name() {
return serverName(live().sdk.server) || id
},
get url() {
return live().sdk.url
},
get password() {
return live().sdk.server.http.password
},
get client() {
return live().sdk.api
},
get data() {
return live().data
},
get local() {
return ServerConnection.local(live().sdk.server)
},
get builtin() {
return ServerConnection.builtin(live().sdk.server)
},
get compatible() {
return !global.servers.health[key]?.incompatible
},
get connected() {
return live().sdk.connection.status() === "connected"
},
}
serverRefs.set(id, ref)
return ref
}
const sessions = createMemo(() => {
const owned = new Set(tabs.store.filter((tab) => tab.type === "session").map(tabKey))
Array.from(refs).forEach(([key, ref]) => {
if (!owned.has(ref.tab)) refs.delete(key)
})
tabs.store.forEach((tab) => {
if (tab.type !== "session") return
const target = server(tab.server)
if (!target) return
Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => {
const key = `${tab.server}\n${id}`
if (refs.has(key)) return
refs.set(key, {
key,
id,
tab: tabKey(tab),
server: target,
get pending() {
return target.data.session.creating(id)
},
get location() {
return target.data.session.get(id)?.location
},
})
})
})
return Array.from(refs.values())
})
const routed = createMemo(() => {
const value = route()
return value.type === "session" ? `${value.server}\n${value.sessionId}` : undefined
})
const current = createMemo(() => {
const key = routed()
const view = routedView()?.()
return key && view?.key === key ? view : undefined
})
const scope = (id: string) => {
const conn = connection(id)
if (!conn) throw new Error(`Server ${id} is unavailable`)
return global.ensureServerCtx(conn).sdk.scope
}
// The registry's scope is pure: an unlisted server's sessions (e.g. a stopped WSL server, whose tabs stay open) keep
// their key, and no server controller starts for it.
const stateKey = (session: SessionRef) => {
const location = session.location
if (!location) return
return SessionStateKey.from(
servers.scope(ServerConnection.Key.make(session.server.id)),
SessionRouteKey.fromRoute(base64Encode(location.directory), session.id),
)
}
// Each session's layout key, kept while its location is unknown (e.g. after the server re-authenticates).
const stateKeys = createMemo<ReadonlyMap<string, string>>(
(previous) =>
new Map(
sessions().flatMap((session) => {
const value = stateKey(session) ?? previous.get(session.key)
return value ? [[session.key, value] as const] : []
}),
),
new Map(),
)
// The strip is stored per directory, so a session that moves copies its strip to its new key: its tabs look the same
// before and after the move. Synchronous, so no region renders the new key before the copy lands. A layout write
// before desktop layout storage loads would stop it loading, so `lastKeys` keeps each session's first key until then.
const lastKeys = new Map<string, string>()
createComputed(() => {
const next = stateKeys()
Array.from(lastKeys.keys()).forEach((key) => {
if (!next.has(key)) lastKeys.delete(key)
})
next.forEach((to, key) => {
if (!lastKeys.has(key)) lastKeys.set(key, to)
})
if (!layout.ready()) return
next.forEach((to, key) => {
const from = lastKeys.get(key)
if (!from || from === to) return
layout.panel.copy(from, to)
lastKeys.set(key, to)
})
})
const shellTab = (session: SessionRef) =>
findSessionTab(tabs.store, ServerConnection.Key.make(session.server.id), session.id)
const sideOpened = (session: SessionRef) => !!tabs.region(shellTab(session), "side")
const dockOpened = (session: SessionRef) => !!tabs.region(shellTab(session), "dock")
const setDock = (session: SessionRef, opened: boolean) => tabs.setRegion(shellTab(session), "dock", opened)
// A token per session whose side region is open, new each time the region opens.
const sideVisits = createMemo<ReadonlyMap<string, object>>(
(previous) =>
new Map(
sessions().flatMap((session) =>
sideOpened(session) ? [[session.key, previous.get(session.key) ?? {}] as const] : [],
),
),
new Map(),
)
// The panel whose toggle opened a side region, by the region's token. However the region closes, it reopens with
// a new token, so a region reopened any other way belongs to the user.
const openedFor = new WeakMap<object, string>()
// Keys are `${extension}:${tab id}`; the extension's panel decides the region.
const provider = (key: string) => {
const extension = key.slice(0, key.indexOf(":"))
const matches = host.items(Panel).filter((item) => item.extension === extension)
return matches.find((item) => item.value.region === "side") ?? matches[0]
}
const mountedSession = (session: SessionRef) => {
const view = current()
return view?.key === session.key ? view : undefined
}
// Counts routing visits: each change of the routed session, including to none (e.g. Home), starts the next one.
const visit = createMemo(on(routed, (_key, _previous, count: number = 0) => count + 1))
// The narrow-screen view belongs to the routed, mounted session for one visit, and reads as the conversation once
// another visit starts. A view selected for a session that is not routed (e.g. a file link that opens Files on
// another session) belongs to the next visit, which the navigation that follows starts. The dock's view follows
// the dock's own per-session state instead.
const [mobile, setMobile] = createStore<{ session: string | undefined; view: string; visit: number }>({
session: undefined,
view: "session",
visit: 0,
})
const mobileView = createMemo(() =>
mobile.session === current()?.key && mobile.visit === visit() ? mobile.view : "session",
)
const selectMobile = (session: SessionRef, view: string) =>
setMobile({ session: session.key, view, visit: session.key === routed() ? visit() : visit() + 1 })
// The side tabs a mounted session lists right now, plus `adding` as if it were stored; unmounted sessions have none.
const listed = (session: SessionRef, value: string, adding?: string) => {
const view = mountedSession(session)
const currentScreen = screen()
if (!view || !currentScreen) return []
const all = layout.panel.state(value).all
const stored = adding && !all.includes(adding) ? [...all, adding] : all
return untrack(() =>
host
.items(Panel)
.filter((item) => item.value.region === "side")
.flatMap((item) => {
const prefix = `${item.extension}:`
const open = stored.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
return item.value
.list({
get session() {
return mountedSession(session) ?? view
},
screen: currentScreen,
open,
})
.map((tab) => ({ key: `${prefix}${tab.id}`, tab }))
}),
)
}
// Writes made before a session's location is known wait for it rather than being dropped.
const located = createLocatedWrites()
const open = (key: string, session: SessionRef, options?: OpenOptions) => {
const item = provider(key)
if (item?.value.region === "dock") return setDock(session, true)
const value = stateKey(session)
if (!value) return located.hold(session, () => open(key, session, options))
const placement = options?.tab ?? "open"
// An append adds the tab quietly: no selection, no region change, no preview replacement.
if (placement === "append") return layout.panel.append(value, key)
// A select keeps the narrow-screen view and dock, as a background open does, and opens the side region too.
if (placement === "select")
return batch(() => {
tabs.setRegion(shellTab(session), "side", true)
layout.panel.append(value, key)
layout.panel.focus(value, key)
})
// Lists the opened tab too, so its own fields apply before it is stored. The next preview replaces a transient tab.
const known = listed(session, value, key)
const transient = new Set(known.flatMap((entry) => (entry.tab.transient ? [entry.key] : [])))
const first = known.some((entry) => entry.key === key && entry.tab.first)
batch(() => {
if (narrow() && !options?.background) {
setDock(session, false)
if (item?.value.mobile) selectMobile(session, `${item.extension}:${item.value.id}`)
// A tab its panel does not list, or a transient one, stays unstored: the open only selects the panel's view.
if (mountedSession(session) && !known.some((entry) => entry.key === key && !entry.tab.transient)) return
}
// A background open keeps the narrow-screen view, but its tab still shows once the window is wide.
if (!narrow() || options?.background) tabs.setRegion(shellTab(session), "side", true)
// Pinned tabs are listed without being stored; opening one only selects it.
if (known.some((entry) => entry.key === key && entry.tab.pinned)) return layout.panel.focus(value, key)
if (placement === "preview") return layout.panel.preview(value, key, transient)
layout.panel.open(value, key, transient, first)
})
}
const close = (key: string, session: SessionRef) => {
const item = provider(key)
if (item?.value.region === "dock") return setDock(session, false)
const value = stateKey(session)
if (!value) return located.hold(session, () => close(key, session))
const tab = listed(session, value).find((entry) => entry.key === key)?.tab
layout.panel.close(value, key)
const view = mountedSession(session)
const currentScreen = screen()
if (view && tab && currentScreen) item?.value.close?.({ tab, session: view, screen: currentScreen })
}
// The routed session's side region, which knows the fallback selection the stored state lacks.
const [region, setRegion] = createSignal<Region>()
// The session screen's inner sidebar preference, which its side panels share.
const [sidebar, setSidebar] = createSignal<PanelSidebar>()
const opened = createMemo(
() => Array.from(new Set((region()?.entries() ?? []).flatMap((entry) => entry.tab.file ?? []))),
[],
{ equals: same },
)
const state = (key: string, session: SessionRef): PanelState => {
if (provider(key)?.value.region === "dock") return dockOpened(session) ? "visible" : "closed"
const value = stateKey(session)
if (!value) return "closed"
const panel = layout.panel.state(value)
const active = mountedSession(session) ? (region()?.active() ?? panel.active) : panel.active
if (active !== key) return panel.all.includes(key) ? "open" : "closed"
return sideOpened(session) ? "visible" : "active"
}
// Open-project requests wait until their server is listed (e.g. an SSH server that just connected).
const picker = useDirectoryPicker()
const [projects, setProjects] = createSignal<readonly { server: string; title: string }[]>([])
createEffect(() => {
const pending = projects()
const ready = pending.flatMap((request) => {
const server = servers.list.find((conn) => ServerConnection.key(conn) === request.server)
return server ? [{ request, server }] : []
})
if (ready.length === 0) return
setProjects(pending.filter((request) => !ready.some((item) => item.request === request)))
untrack(() =>
ready.forEach(({ request, server }) =>
picker({
server,
title: request.title,
onSelect: (value) => {
const directory = Array.isArray(value) ? value[0] : value
if (!directory) return
const key = ServerConnection.key(server)
servers.projects.forServer(key).open(directory)
void tabs.newDraft({ server: key, directory })
},
}),
),
)
})
const toggle = (key: string, session: SessionRef) => {
if (provider(key)?.value.region === "dock") return setDock(session, !dockOpened(session))
const value = stateKey(session)
if (!value) return located.hold(session, () => toggle(key, session))
const region = sideVisits().get(session.key)
if (state(key, session) === "visible") {
batch(() => {
close(key, session)
// Closing the last panel the region was opened for also closes the region.
if (region && openedFor.get(region) === key && layout.panel.state(value).all.length === 0)
tabs.setRegion(shellTab(session), "side", false)
})
return
}
// A panel opened into an open region makes the region the user's.
if (region) openedFor.delete(region)
open(key, session)
const opened = sideVisits().get(session.key)
if (!region && opened) openedFor.set(opened, key)
}
const setScroll = (session: SessionRef, key: string, next: { readonly x: number; readonly y: number }) => {
const value = stateKey(session)
if (!value) return located.hold(session, () => setScroll(session, key, next))
layout.panel.setScroll(value, key, next)
}
const mounted: Attached = {
sessions,
current,
screen,
scope,
scoped: layout.sessionState.track,
project: (server, title) => setProjects((pending) => [...pending, { server, title }]),
font: () => terminalFontFamily(settings.appearance.terminalFont()),
routing: useIsRouting(),
path: () => `${location.pathname}${location.search}`,
keybind: command.keybindParts,
matches: command.matches,
servers: () => global.servers.list().map(ServerConnection.key),
// SAFETY: an extension names a page it contributed through `SettingsPage`, which settings lists as an extension tab.
settings: (page) => surface.open(page as Parameters<typeof surface.open>[0]),
layout: {
ready: layout.ready,
open,
close,
toggle,
state,
stored(extension, session) {
const value = stateKey(session)
if (!value) return []
const prefix = `${extension}:`
return layout.panel
.state(value)
.all.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : []))
},
side: {
opened: sideOpened,
toggle: (session) => tabs.setRegion(shellTab(session), "side", !sideOpened(session)),
},
// Open is the stored preference's default, which holds until a session screen shows the preference.
sidebar: { opened: () => sidebar()?.opened() ?? true },
dock: {
opened: dockOpened,
placement: settings.general.terminalPlacement,
},
scroll: {
get(session, key) {
const value = stateKey(session)
return value ? layout.panel.scroll(value, key) : undefined
},
set: setScroll,
},
},
}
return {
/**
* Mounts the interface for the HostApis; returns its detach. Called as the routes first render, which waits for
* every extension to settle, so writes and dialogs made before then apply after that render.
*/
attach: () => apis.attach(mounted),
/** The routed `MountedSession`. */
current,
region(value: Region) {
setRegion(() => value)
return () => {
if (region() === value) setRegion(undefined)
}
},
/** The session screen's inner sidebar preference, which `Layout.sidebar` reads. */
sidebar(value: PanelSidebar) {
setSidebar(() => value)
return () => {
if (sidebar() === value) setSidebar(undefined)
}
},
/** Workspace files the routed session's side tabs show, in strip order, and the selected one. */
files: {
opened,
active: () => region()?.selected()?.tab.file,
},
mobile: {
current: mobileView,
select(view: string) {
const session = current()
if (session) selectMobile(session, view)
},
},
/** A screen started rendering: `Screen.current` returns it only while its session matches the route. */
screen(value: SessionScreen) {
setScreen(() => value)
return () => {
if (screen() === value) setScreen(undefined)
}
},
/** A session screen mounted: `Sessions.current` reads its object for the routed session. */
mount(view: Accessor<MountedSession>) {
setRoutedView(() => view)
return () => {
if (routedView() === view) setRoutedView(undefined)
}
},
/** Starts loading every extension's declared session stores for a session the screen routes. */
preload(view: MountedSession) {
untrack(() => host.preload(view))
},
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 878 files, more files were not shown because too many files have changed in this diff. Show more