mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaff6ffe2 | |||
| 99cae9766b | |||
| b47cfbee7c | |||
| 5504245f7b | |||
| f64b50d71b | |||
| a7b2ea94e5 | |||
| 7b775c2582 | |||
| 12a931a220 | |||
| 90100c1365 | |||
| 139c9febe4 | |||
| 06290907a9 | |||
| 1c8175a61a | |||
| fe91698ed6 | |||
| 068c32df39 | |||
| a2885d1662 | |||
| 38a3dbb4c4 | |||
| 43383d4fba | |||
| 40c4c3918a | |||
| 754ea99d86 | |||
| 37a1b80d5a | |||
| f95d04fea0 | |||
| 08b80da931 | |||
| f6fb1a7cdd | |||
| 5bcc0016a6 | |||
| 771174b5c3 | |||
| 44cd984589 | |||
| c445d98188 | |||
| 27e7b0558a | |||
| fb975eeb7c | |||
| e556aca833 | |||
| 73bd8a264b | |||
| 077338fcc8 | |||
| b671a77145 | |||
| 30d09a7d7e | |||
| ee02fb4fce | |||
| 010133f6df | |||
| 3b0d8f0e6f | |||
| 4d59b059ee | |||
| 1be6d94267 | |||
| 302e9b45ab | |||
| 7211c9934a | |||
| 3bda0ce123 | |||
| 62320947d9 | |||
| 0cb9bb567e | |||
| b14adcaf83 | |||
| 5bd3da40a5 | |||
| abcbdad530 | |||
| debdea40ea | |||
| 775f24f049 | |||
| 470e360942 | |||
| f15398efc3 | |||
| 4333a44e65 | |||
| 8b4b0d67d7 | |||
| 9200e353bf | |||
| 31124312f6 | |||
| 4f622fa7cd | |||
| 4e4cf9e25e | |||
| 6da2f3c38f | |||
| 1b39d364bd | |||
| 856c569458 | |||
| 92807d0bb9 | |||
| 713658c07b | |||
| f5700808c5 | |||
| 7eb51d0507 | |||
| 02c37c401a | |||
| 33e3d1ebca | |||
| 1f2c59a1b6 | |||
| 65d2a4e00c | |||
| 7d4de3d9e4 | |||
| 9977ef0160 | |||
| 9a55d125f6 | |||
| 766aaf448d | |||
| f14d78afeb | |||
| 0261f04b90 | |||
| 9b49e7bec9 | |||
| 93cb113cef | |||
| 4216d35e4b | |||
| 863645c671 | |||
| 5592f5225b | |||
| 8db7487c89 | |||
| 0fd73a2976 | |||
| 80865407e0 | |||
| 28f4284bd7 | |||
| 7affee529b | |||
| 79c7e9446e | |||
| efb629a33a | |||
| 2ddc91a0e8 | |||
| c7871e14d4 | |||
| 9840f63b12 | |||
| 56a9c0150a | |||
| 203a0613b8 | |||
| 7d8f1bdab3 | |||
| 9eea5bc925 | |||
| f753103e82 | |||
| 1e35d33ecb | |||
| c5bf4edb10 | |||
| cce8bb0e1c | |||
| 02c66c5fc1 | |||
| 33390cc457 | |||
| b2afb35527 | |||
| 828148909d | |||
| 454145fe65 | |||
| 1291dc1f11 | |||
| c7d7f61146 | |||
| 13b6845e7e | |||
| d1d97014b4 | |||
| b31747124b | |||
| 49bec25ae5 | |||
| d66d0cb904 | |||
| 3193f3aa95 | |||
| ee5460a152 | |||
| b09a066fb5 | |||
| 423fad730c | |||
| 5ae2d6d3f6 | |||
| 993f046dd9 | |||
| 9b640cf97d | |||
| 2200d100d0 | |||
| 68ef893818 | |||
| 7ab3dd04ad |
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/client": patch
|
|
||||||
"@opencode-ai/protocol": patch
|
|
||||||
"@opencode-ai/cli": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/cli": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Expose a TUI plugin slot at the top of the session view.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/plugin": minor
|
|
||||||
"@opencode-ai/sdk": minor
|
|
||||||
"@opencode-ai/client": minor
|
|
||||||
"@opencode-ai/protocol": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/client": patch
|
|
||||||
"@opencode-ai/plugin": patch
|
|
||||||
"@opencode-ai/protocol": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/cli": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Expose a TUI plugin slot above the session composer.
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: deploy-www
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- v2
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-www-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: packages/www
|
||||||
|
run: bun run build
|
||||||
|
env:
|
||||||
|
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||||
|
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
working-directory: packages/www
|
||||||
|
run: bun run deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
@@ -99,7 +99,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check generated documentation
|
- name: Check generated documentation
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/docs
|
working-directory: packages/www
|
||||||
run: bun run check:generated
|
run: bun run check:generated
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "test.tui-discovery-smoke",
|
||||||
|
setup(context: Context) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
context.ui.toast.show({
|
||||||
|
title: "TUI plugin discovery works",
|
||||||
|
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||||
|
variant: "success",
|
||||||
|
duration: 30_000,
|
||||||
|
})
|
||||||
|
}, 1_000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
|
||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||||
@@ -61,6 +60,7 @@ const { a, b } = obj
|
|||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
|
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
|
||||||
|
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
|
||||||
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
|
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
|
||||||
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
|
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
|
||||||
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
|
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
exact = true
|
exact = true
|
||||||
# Only install newly resolved package versions published at least 3 days ago.
|
# Only install newly resolved package versions published at least 3 days ago.
|
||||||
minimumReleaseAge = 259200
|
minimumReleaseAge = 259200
|
||||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
root = "./do-not-run-tests-from-root"
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|||||||
+1
-1
@@ -15,6 +15,6 @@
|
|||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@octokit/graphql": "9.0.1",
|
"@octokit/graphql": "9.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*"
|
"@opencode-ai/sdk": "1.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
|
||||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
|
||||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
|
||||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-8
@@ -33,13 +33,12 @@
|
|||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
"packages/stats/*",
|
"packages/stats/*",
|
||||||
"packages/sdk/js",
|
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.98",
|
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||||
"@effect/platform-node": "4.0.0-beta.98",
|
"@effect/platform-node": "4.0.0-beta.101",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
@@ -51,6 +50,7 @@
|
|||||||
"@opentui/solid": "0.4.5",
|
"@opentui/solid": "0.4.5",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
|
"@standard-schema/spec": "1.1.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@corvu/drawer": "0.2.4",
|
"@corvu/drawer": "0.2.4",
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.98",
|
"effect": "4.0.0-beta.101",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "1.18.5",
|
||||||
"heap-snapshot-toolkit": "1.1.3",
|
"heap-snapshot-toolkit": "1.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
@@ -152,7 +152,8 @@
|
|||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:",
|
||||||
|
"effect": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
@@ -166,7 +167,7 @@
|
|||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
|||||||
|
|
||||||
### Auto placement
|
### Auto placement
|
||||||
|
|
||||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
|
||||||
|
|
||||||
|
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
|
||||||
|
|
||||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||||
|
|
||||||
@@ -235,7 +237,7 @@ cache: {
|
|||||||
|
|
||||||
### Manual hints
|
### Manual hints
|
||||||
|
|
||||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -251,8 +253,8 @@ LLM.request({
|
|||||||
|
|
||||||
| Protocol | `cache: "auto"` |
|
| Protocol | `cache: "auto"` |
|
||||||
| ----------------------- | ------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------------------------------------------------- |
|
||||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
|
||||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./testing": "./src/testing.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,32 +2,31 @@
|
|||||||
// the policy designates. Runs once at compile time, before the per-protocol
|
// the policy designates. Runs once at compile time, before the per-protocol
|
||||||
// body builder, so the existing inline-hint lowering path handles the rest.
|
// body builder, so the existing inline-hint lowering path handles the rest.
|
||||||
//
|
//
|
||||||
// The default `"auto"` shape places one breakpoint at the last tool definition,
|
// The default `"auto"` shape places breakpoints at the last tool definition,
|
||||||
// one at the last system part, and one at the latest user message. This
|
// the first and last distinct system parts, and the conversation tail. This
|
||||||
// matches what production agent harnesses (LangChain's caching middleware,
|
// exposes reusable tool, base-agent, project, and session prefixes while
|
||||||
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
|
// advancing the tail after each tool result keeps the previous cache entry
|
||||||
// latest user message stays put while a single turn explodes into many
|
// within Anthropic's 20-block lookback during long agent turns.
|
||||||
// assistant/tool round-trips, so caching at that boundary lets every
|
|
||||||
// intra-turn API call hit the prefix.
|
|
||||||
//
|
//
|
||||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||||
// this function only fills gaps the caller left empty.
|
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||||
|
|
||||||
const AUTO: CachePolicyObject = {
|
const AUTO: CachePolicyObject = {
|
||||||
tools: true,
|
tools: true,
|
||||||
system: true,
|
system: true,
|
||||||
messages: "latest-user-message",
|
messages: { tail: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const NONE: CachePolicyObject = {}
|
const NONE: CachePolicyObject = {}
|
||||||
|
const BREAKPOINT_CAP = 4
|
||||||
|
|
||||||
// Resolution rules:
|
// Resolution rules:
|
||||||
// - undefined → "auto" — caching is on by default. The math favors it:
|
// - undefined → "auto" — caching is on by default. The math favors it:
|
||||||
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
||||||
// so a single reuse within 5 minutes already wins.
|
// so a single reuse within 5 minutes already wins.
|
||||||
// - "auto" → tools + system + latest user msg.
|
// - "auto" → tools + first/last system + final message boundary.
|
||||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||||
// - object form → exactly what the caller asked for.
|
// - object form → exactly what the caller asked for.
|
||||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||||
@@ -44,18 +43,32 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]
|
|||||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||||
|
|
||||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
interface Budget {
|
||||||
|
remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const markLastTool = (
|
||||||
|
tools: ReadonlyArray<ToolDefinition>,
|
||||||
|
hint: CacheHint,
|
||||||
|
budget: Budget,
|
||||||
|
): ReadonlyArray<ToolDefinition> => {
|
||||||
if (tools.length === 0) return tools
|
if (tools.length === 0) return tools
|
||||||
const last = tools.length - 1
|
const last = tools.length - 1
|
||||||
if (tools[last]!.cache) return tools
|
if (tools[last]!.cache || budget.remaining === 0) return tools
|
||||||
|
budget.remaining -= 1
|
||||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||||
}
|
}
|
||||||
|
|
||||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
|
||||||
if (system.length === 0) return system
|
if (system.length === 0) return system
|
||||||
const last = system.length - 1
|
let changed = false
|
||||||
if (system[last]!.cache) return system
|
const next = system.map((part, index) => {
|
||||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
|
||||||
|
budget.remaining -= 1
|
||||||
|
changed = true
|
||||||
|
return { ...part, cache: hint }
|
||||||
|
})
|
||||||
|
return changed ? next : system
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||||
@@ -64,14 +77,20 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
|
|||||||
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
||||||
// the last content part regardless of type — that's the breakpoint position
|
// the last content part regardless of type — that's the breakpoint position
|
||||||
// in tool-result-only messages too.
|
// in tool-result-only messages too.
|
||||||
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
|
const markMessageAt = (
|
||||||
|
messages: ReadonlyArray<Message>,
|
||||||
|
index: number,
|
||||||
|
hint: CacheHint,
|
||||||
|
budget: Budget,
|
||||||
|
): ReadonlyArray<Message> => {
|
||||||
if (index < 0 || index >= messages.length) return messages
|
if (index < 0 || index >= messages.length) return messages
|
||||||
const target = messages[index]!
|
const target = messages[index]!
|
||||||
if (target.content.length === 0) return messages
|
if (target.content.length === 0) return messages
|
||||||
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
||||||
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
||||||
const existing = target.content[markAt]!
|
const existing = target.content[markAt]!
|
||||||
if ("cache" in existing && existing.cache) return messages
|
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
|
||||||
|
budget.remaining -= 1
|
||||||
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
||||||
const next = new Message({ ...target, content: nextContent })
|
const next = new Message({ ...target, content: nextContent })
|
||||||
// Single pass over `messages`, substituting the one updated entry. Long
|
// Single pass over `messages`, substituting the one updated entry. Long
|
||||||
@@ -86,25 +105,42 @@ const markMessages = (
|
|||||||
messages: ReadonlyArray<Message>,
|
messages: ReadonlyArray<Message>,
|
||||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||||
hint: CacheHint,
|
hint: CacheHint,
|
||||||
|
budget: Budget,
|
||||||
): ReadonlyArray<Message> => {
|
): ReadonlyArray<Message> => {
|
||||||
if (messages.length === 0) return messages
|
if (messages.length === 0) return messages
|
||||||
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
|
if (strategy === "latest-user-message")
|
||||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||||
|
if (strategy === "latest-assistant")
|
||||||
|
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||||
const start = Math.max(0, messages.length - strategy.tail)
|
const start = Math.max(0, messages.length - strategy.tail)
|
||||||
let next = messages
|
let next = messages
|
||||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
|
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countHints = (request: LLMRequest) =>
|
||||||
|
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
|
||||||
|
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
|
||||||
|
request.messages.reduce(
|
||||||
|
(count, message) =>
|
||||||
|
count +
|
||||||
|
message.content.reduce(
|
||||||
|
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||||
const policy = resolve(request.cache)
|
const policy = resolve(request.cache)
|
||||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||||
|
|
||||||
const hint = makeHint(policy.ttlSeconds)
|
const hint = makeHint(policy.ttlSeconds)
|
||||||
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
|
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
|
||||||
const system = policy.system ? markLastSystem(request.system, hint) : request.system
|
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
|
||||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
|
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
|
||||||
|
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
|
||||||
|
|
||||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||||
return LLMRequest.update(request, { tools, system, messages })
|
return LLMRequest.update(request, { tools, system, messages })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
@@ -7,8 +8,10 @@ import { Protocol } from "../route/protocol"
|
|||||||
import {
|
import {
|
||||||
LLMError,
|
LLMError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
|
mergeJsonRecords,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
|
type FinishReasonDetails,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
@@ -17,7 +20,6 @@ import {
|
|||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
@@ -233,12 +235,27 @@ const AnthropicBodyFields = {
|
|||||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||||
|
|
||||||
const AnthropicUsage = Schema.Struct({
|
const AnthropicUsage = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
input_tokens: Schema.optional(Schema.Number),
|
input_tokens: Schema.optional(Schema.Number),
|
||||||
output_tokens: Schema.optional(Schema.Number),
|
output_tokens: Schema.optional(Schema.Number),
|
||||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||||
})
|
server_tool_use: optionalNull(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
output_tokens_details: optionalNull(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||||
|
|
||||||
const AnthropicStreamBlock = Schema.Struct({
|
const AnthropicStreamBlock = Schema.Struct({
|
||||||
@@ -288,7 +305,12 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
|||||||
|
|
||||||
interface ParserState {
|
interface ParserState {
|
||||||
readonly tools: ToolStream.State<number>
|
readonly tools: ToolStream.State<number>
|
||||||
|
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
|
readonly pendingFinish?: {
|
||||||
|
readonly reason: FinishReasonDetails
|
||||||
|
readonly providerMetadata?: ProviderMetadata
|
||||||
|
}
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +425,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
|||||||
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||||
// content instead of JSON-stringifying base64 into a prompt string.
|
// content instead of JSON-stringifying base64 into a prompt string.
|
||||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||||
item: ToolContent,
|
item: Tool.Content,
|
||||||
) {
|
) {
|
||||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||||
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||||
@@ -414,7 +436,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
|||||||
// with existing cassettes and provider expectations.
|
// with existing cassettes and provider expectations.
|
||||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -660,9 +682,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||||
// cache reads and writes as separate fields. We sum them to derive the
|
// cache reads and writes as separate fields. We sum them to derive the
|
||||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
// thinking tokens are included in `output_tokens`; newer responses also
|
||||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||||
// `outputTokens` carries the combined total.
|
|
||||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const nonCached = usage.input_tokens
|
const nonCached = usage.input_tokens
|
||||||
@@ -675,6 +696,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
|||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cacheRead,
|
cacheReadInputTokens: cacheRead,
|
||||||
cacheWriteInputTokens: cacheWrite,
|
cacheWriteInputTokens: cacheWrite,
|
||||||
|
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||||
providerMetadata: { anthropic: usage },
|
providerMetadata: { anthropic: usage },
|
||||||
})
|
})
|
||||||
@@ -693,18 +715,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
|||||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||||
|
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
nonCachedInputTokens,
|
nonCachedInputTokens,
|
||||||
cacheReadInputTokens,
|
cacheReadInputTokens,
|
||||||
cacheWriteInputTokens,
|
cacheWriteInputTokens,
|
||||||
|
reasoningTokens,
|
||||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
anthropic: {
|
anthropic:
|
||||||
...left.providerMetadata?.["anthropic"],
|
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||||
...right.providerMetadata?.["anthropic"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -763,6 +785,10 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
tools: ToolStream.start(state.tools, event.index, {
|
tools: ToolStream.start(state.tools, event.index, {
|
||||||
id: block.id ?? String(event.index),
|
id: block.id ?? String(event.index),
|
||||||
name: block.name ?? "",
|
name: block.name ?? "",
|
||||||
|
input:
|
||||||
|
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
||||||
|
? ProviderShared.encodeJson(block.input)
|
||||||
|
: undefined,
|
||||||
providerExecuted: block.type === "server_tool_use",
|
providerExecuted: block.type === "server_tool_use",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -777,20 +803,31 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text !== undefined) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const id = `text-${event.index ?? 0}`
|
||||||
|
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
|
||||||
return [
|
return [
|
||||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
|
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "thinking" && block.thinking) {
|
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const id = `reasoning-${event.index ?? 0}`
|
||||||
|
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||||
|
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
|
lifecycle: block.thinking
|
||||||
|
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||||
|
: lifecycle,
|
||||||
|
reasoningSignatures:
|
||||||
|
event.index === undefined || block.signature === undefined
|
||||||
|
? state.reasoningSignatures
|
||||||
|
: { ...state.reasoningSignatures, [event.index]: block.signature },
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
@@ -799,7 +836,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||||
// payload as `redactedData` metadata (same model as Vercel's
|
// payload as `redactedData` metadata (same model as Vercel's
|
||||||
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||||
if (block.type === "redacted_thinking" && block.data) {
|
if (block.type === "redacted_thinking" && block.data !== undefined) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -847,18 +884,13 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (delta?.type === "signature_delta" && delta.signature) {
|
if (delta?.type === "signature_delta" && delta.signature) {
|
||||||
const events: LLMEvent[] = []
|
const index = event.index ?? 0
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningEnd(
|
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
|
||||||
state.lifecycle,
|
|
||||||
events,
|
|
||||||
`reasoning-${event.index ?? 0}`,
|
|
||||||
anthropicMetadata({ signature: delta.signature }),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
events,
|
NO_EVENTS,
|
||||||
] satisfies StepResult
|
] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,31 +921,53 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
|||||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const resultEvents = result.events ?? []
|
const resultEvents = result.events ?? []
|
||||||
|
const signature = state.reasoningSignatures[event.index]
|
||||||
const lifecycle = resultEvents.length
|
const lifecycle = resultEvents.length
|
||||||
? Lifecycle.stepStart(state.lifecycle, events)
|
? Lifecycle.stepStart(state.lifecycle, events)
|
||||||
: Lifecycle.reasoningEnd(
|
: Lifecycle.reasoningEnd(
|
||||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||||
events,
|
events,
|
||||||
`reasoning-${event.index}`,
|
`reasoning-${event.index}`,
|
||||||
|
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||||
)
|
)
|
||||||
events.push(...resultEvents)
|
events.push(...resultEvents)
|
||||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||||
|
delete reasoningSignatures[event.index]
|
||||||
|
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
|
||||||
})
|
})
|
||||||
|
|
||||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||||
const events: LLMEvent[] = []
|
return [
|
||||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
{
|
||||||
|
...state,
|
||||||
|
usage,
|
||||||
|
pendingFinish: {
|
||||||
reason: {
|
reason: {
|
||||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||||
raw: event.delta?.stop_reason ?? undefined,
|
raw: event.delta?.stop_reason ?? undefined,
|
||||||
},
|
},
|
||||||
usage,
|
providerMetadata:
|
||||||
providerMetadata: event.delta?.stop_sequence
|
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
? undefined
|
||||||
: undefined,
|
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NO_EVENTS,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMessageStop = (state: ParserState): StepResult => {
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||||
|
reason: state.pendingFinish?.reason ?? {
|
||||||
|
normalized: "unknown",
|
||||||
|
raw: undefined,
|
||||||
|
},
|
||||||
|
usage: state.usage,
|
||||||
|
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||||
})
|
})
|
||||||
return [{ ...state, lifecycle, usage }, events]
|
return [{ ...state, lifecycle }, events]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||||
@@ -938,6 +992,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
|||||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||||
|
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
|
||||||
if (event.type === "error") return onError(event)
|
if (event.type === "error") return onError(event)
|
||||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||||
}
|
}
|
||||||
@@ -958,7 +1013,11 @@ export const protocol = Protocol.make({
|
|||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(AnthropicEvent),
|
event: Protocol.jsonEvent(AnthropicEvent),
|
||||||
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
|
initial: () => ({
|
||||||
|
tools: ToolStream.empty<number>(),
|
||||||
|
reasoningSignatures: {},
|
||||||
|
lifecycle: Lifecycle.initial(),
|
||||||
|
}),
|
||||||
step,
|
step,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||||
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||||
const media: GeminiInlineDataPart[] = []
|
const media: GeminiInlineDataPart[] = []
|
||||||
for (const item of content) {
|
for (const item of content) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import { HttpTransport } from "../route/transport"
|
import { HttpTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,6 @@ import {
|
|||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
@@ -55,6 +55,9 @@ const OpenResponsesOutputText = Schema.Struct({
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||||
|
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||||
|
|
||||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||||
type: Schema.tag("summary_text"),
|
type: Schema.tag("summary_text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
@@ -86,10 +89,14 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
|||||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||||
])
|
])
|
||||||
|
|
||||||
const OpenResponsesInputItem = Schema.Union([
|
export const InputItem = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
|
Schema.Struct({
|
||||||
|
role: Schema.tag("assistant"),
|
||||||
|
content: Schema.Array(OpenResponsesOutputText),
|
||||||
|
phase: Schema.optionalKey(MessagePhase),
|
||||||
|
}),
|
||||||
OpenResponsesReasoningItem,
|
OpenResponsesReasoningItem,
|
||||||
OpenResponsesItemReference,
|
OpenResponsesItemReference,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
@@ -104,7 +111,14 @@ const OpenResponsesInputItem = Schema.Union([
|
|||||||
output: OpenResponsesFunctionCallOutput,
|
output: OpenResponsesFunctionCallOutput,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
|
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||||
|
type LoweredInputItem =
|
||||||
|
| OpenResponsesInputItem
|
||||||
|
| {
|
||||||
|
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
|
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||||
// multiple streamed summary parts into the same item before flushing.
|
// multiple streamed summary parts into the same item before flushing.
|
||||||
@@ -135,7 +149,7 @@ export const ToolChoice = Schema.Union([
|
|||||||
// transports in sync without a destructure-and-strip dance.
|
// transports in sync without a destructure-and-strip dance.
|
||||||
export const coreFields = {
|
export const coreFields = {
|
||||||
model: Schema.String,
|
model: Schema.String,
|
||||||
input: Schema.Array(OpenResponsesInputItem),
|
input: Schema.Array(InputItem),
|
||||||
instructions: Schema.optional(Schema.String),
|
instructions: Schema.optional(Schema.String),
|
||||||
tools: optionalArray(Tool),
|
tools: optionalArray(Tool),
|
||||||
tool_choice: Schema.optional(ToolChoice),
|
tool_choice: Schema.optional(ToolChoice),
|
||||||
@@ -167,7 +181,12 @@ export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
|||||||
|
|
||||||
const OpenResponsesUsage = Schema.Struct({
|
const OpenResponsesUsage = Schema.Struct({
|
||||||
input_tokens: Schema.optional(Schema.Number),
|
input_tokens: Schema.optional(Schema.Number),
|
||||||
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
|
input_tokens_details: optionalNull(
|
||||||
|
Schema.Struct({
|
||||||
|
cached_tokens: Schema.optional(Schema.Number),
|
||||||
|
cache_write_tokens: Schema.optional(Schema.Number),
|
||||||
|
}),
|
||||||
|
),
|
||||||
output_tokens: Schema.optional(Schema.Number),
|
output_tokens: Schema.optional(Schema.Number),
|
||||||
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
|
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
|
||||||
total_tokens: Schema.optional(Schema.Number),
|
total_tokens: Schema.optional(Schema.Number),
|
||||||
@@ -201,6 +220,7 @@ export const Event = Schema.StructWithRest(
|
|||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
delta: Schema.optional(Schema.String),
|
delta: Schema.optional(Schema.String),
|
||||||
|
text: Schema.optional(Schema.String),
|
||||||
item_id: Schema.optional(Schema.String),
|
item_id: Schema.optional(Schema.String),
|
||||||
summary_index: Schema.optional(Schema.Number),
|
summary_index: Schema.optional(Schema.Number),
|
||||||
item: Schema.optional(StreamItem),
|
item: Schema.optional(StreamItem),
|
||||||
@@ -233,6 +253,7 @@ export interface Extension {
|
|||||||
readonly media: ProviderShared.ValidatedMedia
|
readonly media: ProviderShared.ValidatedMedia
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
}) => MediaInput | undefined
|
}) => MediaInput | undefined
|
||||||
|
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||||
@@ -244,6 +265,9 @@ export interface ParserState {
|
|||||||
readonly tools: ToolStream.State<string>
|
readonly tools: ToolStream.State<string>
|
||||||
readonly hasFunctionCall: boolean
|
readonly hasFunctionCall: boolean
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
|
readonly messageItems: ReadonlySet<string>
|
||||||
|
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||||
|
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||||
readonly store: boolean | undefined
|
readonly store: boolean | undefined
|
||||||
}
|
}
|
||||||
@@ -347,7 +371,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
|
|||||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||||
// content instead of JSON-stringifying base64 into a prompt string.
|
// content instead of JSON-stringifying base64 into a prompt string.
|
||||||
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
|
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
|
||||||
item: ToolContent,
|
item: Content,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
extension: Extension,
|
extension: Extension,
|
||||||
) {
|
) {
|
||||||
@@ -368,14 +392,14 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
|||||||
// compatibility with existing cassettes and provider expectations.
|
// compatibility with existing cassettes and provider expectations.
|
||||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Content> = part.result.value
|
||||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||||
const system: OpenResponsesInputItem[] =
|
const system: LoweredInputItem[] =
|
||||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||||
const input: OpenResponsesInputItem[] = [...system]
|
const input: LoweredInputItem[] = [...system]
|
||||||
const store = OpenResponsesOptions.resolve(request).store
|
const store = OpenResponsesOptions.resolve(request).store
|
||||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||||
|
|
||||||
@@ -407,7 +431,27 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
const hostedToolReferences = new Set<string>()
|
const hostedToolReferences = new Set<string>()
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
if (content.length === 0) return
|
if (content.length === 0) return
|
||||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||||
|
(groups, part) => {
|
||||||
|
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||||
|
const phase =
|
||||||
|
ProviderShared.isRecord(metadata)
|
||||||
|
? messagePhase(metadata.phase, extension)
|
||||||
|
: undefined
|
||||||
|
const group = groups.at(-1)
|
||||||
|
if (group && group.phase === phase) group.parts.push(part)
|
||||||
|
else groups.push({ phase, parts: [part] })
|
||||||
|
return groups
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
input.push(
|
||||||
|
...groups.map((group) => ({
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||||
|
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||||
|
})),
|
||||||
|
)
|
||||||
content.splice(0, content.length)
|
content.splice(0, content.length)
|
||||||
}
|
}
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
@@ -452,7 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||||
input.push({ type: "item_reference", id: itemID })
|
input.push({ type: "item_reference", id: itemID })
|
||||||
if (store === false && part.result.type === "content") {
|
if (store === false && part.result.type === "content") {
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Content> = part.result.value
|
||||||
input.push({
|
input.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||||
@@ -508,9 +552,9 @@ const lowerOptions = (request: LLMRequest) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
extension: Extension = BASE,
|
extension: Extension,
|
||||||
) {
|
) {
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
@@ -536,23 +580,31 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||||
|
|
||||||
|
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||||
|
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||||
|
})
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Stream Parsing
|
// Stream Parsing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Responses APIs report `input_tokens` (inclusive total) with a
|
// Responses APIs report `input_tokens` (inclusive total) with a
|
||||||
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
|
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
|
||||||
// `reasoning_tokens` subset. Pass the totals through and derive the
|
// with a `reasoning_tokens` subset. Pass the totals through and derive the
|
||||||
// non-cached breakdown.
|
// non-cached breakdown.
|
||||||
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const cached = usage.input_tokens_details?.cached_tokens
|
const cached = usage.input_tokens_details?.cached_tokens
|
||||||
|
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
|
||||||
const reasoning = usage.output_tokens_details?.reasoning_tokens
|
const reasoning = usage.output_tokens_details?.reasoning_tokens
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
|
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.input_tokens,
|
inputTokens: usage.input_tokens,
|
||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cached,
|
cacheReadInputTokens: cached,
|
||||||
|
cacheWriteInputTokens: cacheWrite,
|
||||||
reasoningTokens: reasoning,
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
||||||
providerMetadata: { [providerMetadataKey]: usage },
|
providerMetadata: { [providerMetadataKey]: usage },
|
||||||
@@ -588,24 +640,30 @@ const NO_EVENTS: StepResult["1"] = []
|
|||||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const phase = state.messagePhases[id]
|
||||||
|
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||||
|
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||||
return [
|
return [
|
||||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
|
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||||
|
if (state.messageItems.has(id)) {
|
||||||
|
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
|
||||||
|
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||||
|
}
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
|
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const itemID = event.item_id ?? "reasoning-0"
|
|
||||||
const id =
|
const id =
|
||||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||||
return [
|
return [
|
||||||
@@ -636,6 +694,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
|||||||
// best-effort, not guaranteed.
|
// best-effort, not guaranteed.
|
||||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||||
const item = event.item
|
const item = event.item
|
||||||
|
if (item?.type === "message" && item.id)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
messageItems: new Set([...state.messageItems, item.id]),
|
||||||
|
messagePhases: (() => {
|
||||||
|
const phase = state.messagePhase(item.phase)
|
||||||
|
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
NO_EVENTS,
|
||||||
|
]
|
||||||
if (item && isReasoningItem(item)) {
|
if (item && isReasoningItem(item)) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [
|
||||||
@@ -792,7 +862,28 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
const item = event.item
|
const item = event.item
|
||||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
|
||||||
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
|
if (item.type === "message" && item.id) {
|
||||||
|
const itemPhase = state.messagePhase(item.phase)
|
||||||
|
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const messageItems = new Set(state.messageItems)
|
||||||
|
messageItems.delete(item.id)
|
||||||
|
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
lifecycle: Lifecycle.textEnd(
|
||||||
|
state.lifecycle,
|
||||||
|
events,
|
||||||
|
item.id,
|
||||||
|
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||||
|
),
|
||||||
|
messageItems,
|
||||||
|
messagePhases,
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
] satisfies StepResult
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === "function_call") {
|
if (item.type === "function_call") {
|
||||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||||
@@ -892,19 +983,41 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const step = (state: ParserState, event: Event) => {
|
export const step = (state: ParserState, event: Event) => {
|
||||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||||
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
|
return Effect.succeed(
|
||||||
return Effect.succeed(onReasoningDelta(state, event))
|
event.type === "response.output_text.delta"
|
||||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
|
? onOutputTextDelta(state, event, event.item_id)
|
||||||
|
: onOutputTextDone(state, event, event.item_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||||
|
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
|
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||||
|
}
|
||||||
|
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||||
|
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
return Effect.succeed(onReasoningDone(state, event))
|
return Effect.succeed(onReasoningDone(state, event))
|
||||||
|
}
|
||||||
if (event.type === "response.reasoning_summary_part.added")
|
if (event.type === "response.reasoning_summary_part.added")
|
||||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
return event.item_id
|
||||||
|
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||||
|
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
if (event.type === "response.reasoning_summary_part.done")
|
if (event.type === "response.reasoning_summary_part.done")
|
||||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
return event.item_id
|
||||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||||
|
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
|
if (event.type === "response.output_item.added") {
|
||||||
|
if (event.item?.type === "message" && !event.item.id)
|
||||||
|
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||||
|
return Effect.succeed(onOutputItemAdded(state, event))
|
||||||
|
}
|
||||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
if (event.type === "response.output_item.done") {
|
||||||
|
if (event.item?.type === "message" && !event.item.id)
|
||||||
|
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||||
|
return onOutputItemDone(state, event)
|
||||||
|
}
|
||||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||||
return Effect.succeed(onResponseFinish(state, event))
|
return Effect.succeed(onResponseFinish(state, event))
|
||||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||||
@@ -926,10 +1039,18 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
|||||||
hasFunctionCall: false,
|
hasFunctionCall: false,
|
||||||
tools: ToolStream.empty<string>(),
|
tools: ToolStream.empty<string>(),
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
|
messageItems: new Set<string>(),
|
||||||
|
messagePhase: (value) => messagePhase(value, extension),
|
||||||
|
messagePhases: {},
|
||||||
reasoningItems: {},
|
reasoningItems: {},
|
||||||
store: OpenResponsesOptions.resolve(request).store,
|
store: OpenResponsesOptions.resolve(request).store,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||||
|
if (value === "commentary" || value === "final_answer") return value
|
||||||
|
return extension.messagePhase?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
export const protocol = Protocol.make({
|
export const protocol = Protocol.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { classifyProviderFailure } from "../provider-error"
|
import { classifyProviderFailure } from "../provider-error"
|
||||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
@@ -131,6 +131,7 @@ const OpenAIChatUsage = Schema.Struct({
|
|||||||
prompt_tokens_details: optionalNull(
|
prompt_tokens_details: optionalNull(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
cached_tokens: Schema.optional(Schema.Number),
|
cached_tokens: Schema.optional(Schema.Number),
|
||||||
|
cache_write_tokens: Schema.optional(Schema.Number),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
completion_tokens_details: optionalNull(
|
completion_tokens_details: optionalNull(
|
||||||
@@ -334,7 +335,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
|||||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||||
const files = content.filter((item) => item.type === "file")
|
const files = content.filter((item) => item.type === "file")
|
||||||
@@ -453,20 +454,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||||
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
|
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
|
||||||
// a `reasoning_tokens` subset. We pass the inclusive totals through and
|
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||||
// derive the non-cached breakdown so the `LLM.Usage` contract is
|
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||||
// satisfied on both sides.
|
// satisfied on both sides.
|
||||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||||
|
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
||||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
|
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.prompt_tokens,
|
inputTokens: usage.prompt_tokens,
|
||||||
outputTokens: usage.completion_tokens,
|
outputTokens: usage.completion_tokens,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cached,
|
cacheReadInputTokens: cached,
|
||||||
|
cacheWriteInputTokens: cacheWrite,
|
||||||
reasoningTokens: reasoning,
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||||
providerMetadata: { openai: usage },
|
providerMetadata: { openai: usage },
|
||||||
|
|||||||
@@ -35,8 +35,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
|||||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const OpenAIResponsesInputItem = Schema.Union([
|
||||||
|
Schema.Struct({
|
||||||
|
role: Schema.tag("assistant"),
|
||||||
|
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||||
|
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||||
|
}),
|
||||||
|
OpenResponses.InputItem,
|
||||||
|
])
|
||||||
|
|
||||||
const OpenAIResponsesCoreFields = {
|
const OpenAIResponsesCoreFields = {
|
||||||
...OpenResponses.coreFields,
|
...OpenResponses.coreFields,
|
||||||
|
input: Schema.Array(OpenAIResponsesInputItem),
|
||||||
tools: optionalArray(OpenAIResponsesTools),
|
tools: optionalArray(OpenAIResponsesTools),
|
||||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||||
}
|
}
|
||||||
@@ -60,6 +70,7 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
|
|||||||
const extension = {
|
const extension = {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
name: NAME,
|
name: NAME,
|
||||||
|
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||||
lowerMedia: ({ part, media, request }) => {
|
lowerMedia: ({ part, media, request }) => {
|
||||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||||
return {
|
return {
|
||||||
@@ -102,7 +113,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||||
const body = yield* OpenResponses.fromRequest(
|
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||||
extension,
|
extension,
|
||||||
)
|
)
|
||||||
@@ -208,9 +219,13 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
|||||||
|
|
||||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||||
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
|
return event.item_id
|
||||||
|
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||||
|
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||||
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
return event.item_id
|
||||||
|
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||||
|
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||||
return onHostedToolDone(state, event.item)
|
return onHostedToolDone(state, event.item)
|
||||||
return OpenResponses.step(state, event)
|
return OpenResponses.step(state, event)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Buffer } from "node:buffer"
|
import { Buffer } from "node:buffer"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import * as Sse from "effect/unstable/encoding/Sse"
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
type ContentPart,
|
type ContentPart,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
type ToolFileContent,
|
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
|
|||||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
|
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
|
||||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||||
|
|
||||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||||
|
|||||||
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
|||||||
return { ...state, stepStarted: true }
|
return { ...state, stepStarted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
|
if (state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
if (stepped.text.has(id)) {
|
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||||
events.push(LLMEvent.textDelta({ id, text }))
|
|
||||||
return stepped
|
|
||||||
}
|
|
||||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
|
||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||||
|
const started = textStart(state, events, id)
|
||||||
|
events.push(LLMEvent.textDelta({ id, text }))
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
export const reasoningStart = (
|
export const reasoningStart = (
|
||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
rateLimit: input.rateLimit,
|
rateLimit: input.rateLimit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (input.status !== undefined && input.status >= 500)
|
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||||
return new ProviderInternalReason({
|
return new ProviderInternalReason({
|
||||||
...common,
|
...common,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
@@ -145,7 +145,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
if (
|
if (
|
||||||
input.status === 400 ||
|
input.status === 400 ||
|
||||||
input.status === 404 ||
|
input.status === 404 ||
|
||||||
input.status === 409 ||
|
|
||||||
input.status === 413 ||
|
input.status === 413 ||
|
||||||
input.status === 422
|
input.status === 422
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||||
|
|
||||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||||
@@ -152,8 +153,4 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
|||||||
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
||||||
* treated as a defect and fails the stream.
|
* treated as a defect and fails the stream.
|
||||||
*/
|
*/
|
||||||
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
|
export class ToolFailure extends Tool.Error {}
|
||||||
message: Schema.String,
|
|
||||||
error: Schema.optional(Schema.Defect()),
|
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
||||||
}) {}
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
|
|||||||
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||||
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||||
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
* Anthropic's `outputTokens` includes extended thinking. Newer responses
|
||||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
|
||||||
* combined total — a documented limitation of the Anthropic API.
|
* to `reasoningTokens`; older responses leave it undefined.
|
||||||
*
|
*
|
||||||
* `providerMetadata` always carries the provider's raw usage payload —
|
* `providerMetadata` always carries the provider's raw usage payload —
|
||||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
|
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
|
||||||
import { isRecord } from "../utils/record"
|
import { isRecord } from "../utils/record"
|
||||||
@@ -40,8 +40,6 @@ export const MediaPart = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Content.Media" })
|
}).annotate({ identifier: "LLM.Content.Media" })
|
||||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||||
|
|
||||||
export { ToolContent, ToolFileContent, ToolTextContent }
|
|
||||||
|
|
||||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||||
@@ -63,7 +61,7 @@ export const ToolResultValue = Object.assign(
|
|||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("content"),
|
type: Schema.Literal("content"),
|
||||||
value: Schema.Array(ToolContent),
|
value: Schema.Array(Tool.Content),
|
||||||
}),
|
}),
|
||||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
]).annotate({ identifier: "LLM.ToolResult" }),
|
||||||
{
|
{
|
||||||
@@ -79,16 +77,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
|||||||
|
|
||||||
export interface ToolOutput {
|
export interface ToolOutput {
|
||||||
readonly structured: unknown
|
readonly structured: unknown
|
||||||
readonly content: ReadonlyArray<ToolContent>
|
readonly content: ReadonlyArray<Tool.Content>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolOutput = Object.assign(
|
export const ToolOutput = Object.assign(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
structured: Schema.Unknown,
|
structured: Schema.Unknown,
|
||||||
content: Schema.Array(ToolContent),
|
content: Schema.Array(Tool.Content),
|
||||||
}).annotate({ identifier: "LLM.ToolOutput" }),
|
}).annotate({ identifier: "LLM.ToolOutput" }),
|
||||||
{
|
{
|
||||||
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
|
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }),
|
||||||
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
|
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case "json":
|
case "json":
|
||||||
|
|||||||
@@ -251,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
|||||||
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
||||||
// reads this and injects `CacheHint`s at the configured boundaries; the
|
// reads this and injects `CacheHint`s at the configured boundaries; the
|
||||||
// per-protocol body builders then translate those hints into wire markers as
|
// per-protocol body builders then translate those hints into wire markers as
|
||||||
// usual. `"auto"` is the recommended default for agent loops — it places one
|
// usual. `"auto"` is the recommended default for agent loops — it places
|
||||||
// breakpoint at the last tool definition, one at the last system part, and one
|
// breakpoints at the last tool definition, the first and last distinct system
|
||||||
// at the latest user message. The combination of provider invalidation
|
// parts, and the conversation tail. The rolling message breakpoint keeps a
|
||||||
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
|
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
|
||||||
// lookback means three trailing breakpoints reliably cover the static prefix.
|
// tool loops.
|
||||||
//
|
//
|
||||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||||
// object form to override individual choices.
|
// object form to override individual choices.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
export * as TestLLM from "./testing"
|
||||||
|
|
||||||
|
import { LLMClient, type Interface as LLMClientShape } from "./route/client"
|
||||||
|
import {
|
||||||
|
LLMEvent,
|
||||||
|
LLMResponse,
|
||||||
|
type FinishReasonDetails,
|
||||||
|
type LLMError,
|
||||||
|
type LLMRequest,
|
||||||
|
type UsageInput,
|
||||||
|
} from "./schema"
|
||||||
|
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||||
|
|
||||||
|
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||||
|
|
||||||
|
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly requests: LLMRequest[]
|
||||||
|
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||||
|
readonly always: (response: Response) => Effect.Effect<void>
|
||||||
|
readonly wait: (count: number) => Effect.Effect<void>
|
||||||
|
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
|
||||||
|
readonly client: LLMClientShape
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerOptions {
|
||||||
|
readonly transformRequest?: (request: LLMRequest) => LLMRequest
|
||||||
|
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
|
||||||
|
readonly fallback?: Response
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||||
|
|
||||||
|
export const complete = (
|
||||||
|
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
|
||||||
|
...events: readonly LLMEvent[]
|
||||||
|
) => [
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
...events,
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
|
||||||
|
LLMEvent.finish({ reason: options.reason }),
|
||||||
|
]
|
||||||
|
|
||||||
|
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
|
||||||
|
|
||||||
|
export const toolCalls = (...events: readonly LLMEvent[]) =>
|
||||||
|
complete({ reason: { normalized: "tool-calls" } }, ...events)
|
||||||
|
|
||||||
|
const textEvents = (value: string, id: string) => [
|
||||||
|
LLMEvent.textStart({ id }),
|
||||||
|
LLMEvent.textDelta({ id, text: value }),
|
||||||
|
LLMEvent.textEnd({ id }),
|
||||||
|
]
|
||||||
|
|
||||||
|
export const text = (value: string, id: string) => stop(...textEvents(value, id))
|
||||||
|
|
||||||
|
export const textWithUsage = (value: string, id: string, inputTokens: number) =>
|
||||||
|
complete(
|
||||||
|
{ reason: { normalized: "stop" }, usage: { inputTokens, nonCachedInputTokens: inputTokens } },
|
||||||
|
...textEvents(value, id),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
|
||||||
|
|
||||||
|
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) =>
|
||||||
|
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
|
||||||
|
|
||||||
|
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
|
||||||
|
|
||||||
|
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||||
|
|
||||||
|
export const layer = (options: LayerOptions = {}) =>
|
||||||
|
Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const requests: LLMRequest[] = []
|
||||||
|
const responses: Response[] = []
|
||||||
|
let started = Deferred.makeUnsafe<void>()
|
||||||
|
let fallback = options.fallback
|
||||||
|
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||||
|
const wait = (count: number): Effect.Effect<void> =>
|
||||||
|
Effect.suspend(() =>
|
||||||
|
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const stream = ((request: LLMRequest) => {
|
||||||
|
requests.push(options.transformRequest?.(request) ?? request)
|
||||||
|
const waiting = started
|
||||||
|
started = Deferred.makeUnsafe()
|
||||||
|
Deferred.doneUnsafe(waiting, Effect.void)
|
||||||
|
const response = responses.shift() ?? fallback
|
||||||
|
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
|
||||||
|
const streamed = toStream(response)
|
||||||
|
const gate = activeGate
|
||||||
|
if (!gate) return streamed
|
||||||
|
return Stream.unwrap(
|
||||||
|
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||||
|
)
|
||||||
|
}) as LLMClientShape["stream"]
|
||||||
|
const client = LLMClient.Service.of({
|
||||||
|
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
|
||||||
|
stream,
|
||||||
|
generate: (request) =>
|
||||||
|
stream(request).pipe(
|
||||||
|
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||||
|
Effect.flatMap((state) => {
|
||||||
|
const response = LLMResponse.complete(state)
|
||||||
|
if (response) return Effect.succeed(response)
|
||||||
|
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
requests,
|
||||||
|
push: (...input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
responses.push(...input)
|
||||||
|
}),
|
||||||
|
always: (response) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
fallback = response
|
||||||
|
}),
|
||||||
|
wait,
|
||||||
|
gate: Effect.gen(function* () {
|
||||||
|
const gate = {
|
||||||
|
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
|
||||||
|
release: yield* Latch.make(),
|
||||||
|
}
|
||||||
|
activeGate = gate
|
||||||
|
const release = Effect.sync(() => {
|
||||||
|
if (activeGate === gate) activeGate = undefined
|
||||||
|
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
|
||||||
|
yield* Effect.addFinalizer(() => release)
|
||||||
|
return {
|
||||||
|
started: Queue.take(gate.started),
|
||||||
|
release,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
client,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const clientLayer = Layer.effect(
|
||||||
|
LLMClient.Service,
|
||||||
|
Effect.map(Service, (service) => service.client),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const push = (...responses: readonly Response[]) => Service.use((service) => service.push(...responses))
|
||||||
|
|
||||||
|
export const always = (response: Response) => Service.use((service) => service.always(response))
|
||||||
|
|
||||||
|
export const wait = (count: number) => Service.use((service) => service.wait(count))
|
||||||
|
|
||||||
|
export const gate = Service.use((service) => service.gate)
|
||||||
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
|
|||||||
|
|
||||||
return decodeAndExecute(tool, call).pipe(
|
return decodeAndExecute(tool, call).pipe(
|
||||||
Effect.map((value) => result(call, value)),
|
Effect.map((value) => result(call, value)),
|
||||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
Effect.catchTag("Tool.Error", (failure) =>
|
||||||
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
|
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Effect, JsonSchema, Schema } from "effect"
|
import { Effect, JsonSchema, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import type {
|
import type {
|
||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
ToolContent,
|
|
||||||
ToolDefinition as ToolDefinitionClass,
|
ToolDefinition as ToolDefinitionClass,
|
||||||
ToolOutput as ToolOutputType,
|
ToolOutput as ToolOutputType,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
|
|||||||
|
|
||||||
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
|
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
|
||||||
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
|
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
|
||||||
) => ReadonlyArray<ToolContent>
|
) => ReadonlyArray<Tool.Content>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type-safe LLM tool. Each tool bundles its own description, parameter
|
* A type-safe LLM tool. Each tool bundles its own description, parameter
|
||||||
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
|
|||||||
readonly jsonSchema: JsonSchema.JsonSchema
|
readonly jsonSchema: JsonSchema.JsonSchema
|
||||||
readonly outputSchema?: JsonSchema.JsonSchema
|
readonly outputSchema?: JsonSchema.JsonSchema
|
||||||
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ export function make(config: {
|
|||||||
readonly jsonSchema: JsonSchema.JsonSchema
|
readonly jsonSchema: JsonSchema.JsonSchema
|
||||||
readonly outputSchema?: JsonSchema.JsonSchema
|
readonly outputSchema?: JsonSchema.JsonSchema
|
||||||
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||||
}): AnyExecutableTool
|
}): AnyExecutableTool
|
||||||
export function make(config: {
|
export function make(config: {
|
||||||
@@ -159,7 +159,7 @@ export function make(config: {
|
|||||||
readonly jsonSchema: JsonSchema.JsonSchema
|
readonly jsonSchema: JsonSchema.JsonSchema
|
||||||
readonly outputSchema?: JsonSchema.JsonSchema
|
readonly outputSchema?: JsonSchema.JsonSchema
|
||||||
readonly execute?: undefined
|
readonly execute?: undefined
|
||||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||||
}): AnyTool
|
}): AnyTool
|
||||||
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||||
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = (
|
const project = (
|
||||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
|
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
|
||||||
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
||||||
parameters: unknown,
|
parameters: unknown,
|
||||||
callID: ToolCallPart["id"],
|
callID: ToolCallPart["id"],
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// No explicit cache field → auto policy fires → last system part + latest
|
// A single system block is both the first and last boundary, so the auto
|
||||||
// user message both get cache_control markers.
|
// policy deduplicates it and still marks the conversation tail.
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toMatchObject({
|
||||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||||
@@ -48,12 +48,15 @@ describe("applyCachePolicy", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
|
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: anthropicModel,
|
model: anthropicModel,
|
||||||
system: "Sys A",
|
system: [
|
||||||
|
{ type: "text", text: "Base agent" },
|
||||||
|
{ type: "text", text: "Project instructions" },
|
||||||
|
],
|
||||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||||
messages: [
|
messages: [
|
||||||
Message.user("first user"),
|
Message.user("first user"),
|
||||||
@@ -66,7 +69,10 @@ describe("applyCachePolicy", () => {
|
|||||||
|
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toMatchObject({
|
||||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||||
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
|
system: [
|
||||||
|
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
|
||||||
|
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||||
|
],
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
||||||
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
||||||
@@ -120,7 +126,10 @@ describe("applyCachePolicy", () => {
|
|||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: bedrockModel,
|
model: bedrockModel,
|
||||||
system: "Sys",
|
system: [
|
||||||
|
{ type: "text", text: "Base agent" },
|
||||||
|
{ type: "text", text: "Project instructions" },
|
||||||
|
],
|
||||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||||
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
|
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
|
||||||
cache: "auto",
|
cache: "auto",
|
||||||
@@ -131,7 +140,12 @@ describe("applyCachePolicy", () => {
|
|||||||
toolConfig: {
|
toolConfig: {
|
||||||
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
||||||
},
|
},
|
||||||
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
|
system: [
|
||||||
|
{ text: "Base agent" },
|
||||||
|
{ cachePoint: { type: "default" } },
|
||||||
|
{ text: "Project instructions" },
|
||||||
|
{ cachePoint: { type: "default" } },
|
||||||
|
],
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: [{ text: "first user" }] },
|
{ role: "user", content: [{ text: "first user" }] },
|
||||||
{ role: "assistant", content: [{ text: "reply" }] },
|
{ role: "assistant", content: [{ text: "reply" }] },
|
||||||
@@ -193,9 +207,55 @@ describe("applyCachePolicy", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
|
const body = prepared.body as {
|
||||||
|
system: Array<{ text: string; cache_control?: unknown }>
|
||||||
|
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||||
|
}
|
||||||
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
|
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
|
||||||
|
expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = LLM.request({
|
||||||
|
model: anthropicModel,
|
||||||
|
system: [
|
||||||
|
{ type: "text", text: "Base agent" },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Manual context",
|
||||||
|
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
|
||||||
|
},
|
||||||
|
{ type: "text", text: "Project instructions" },
|
||||||
|
],
|
||||||
|
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||||
|
prompt: "hi",
|
||||||
|
cache: "auto",
|
||||||
|
})
|
||||||
|
const applied = applyCachePolicy(request)
|
||||||
|
expect(applied.tools[0]?.cache).toBeDefined()
|
||||||
|
expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
|
||||||
|
const tail = applied.messages[0]!.content[0]!
|
||||||
|
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
|
||||||
|
expect(applyCachePolicy(applied)).toBe(applied)
|
||||||
|
|
||||||
|
const prepared = yield* LLMClient.prepare(request)
|
||||||
|
|
||||||
|
const body = prepared.body as {
|
||||||
|
tools: Array<{ cache_control?: unknown }>
|
||||||
|
system: Array<{ cache_control?: unknown }>
|
||||||
|
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||||
|
}
|
||||||
|
const marked = [
|
||||||
|
...body.tools.map((tool) => tool.cache_control),
|
||||||
|
...body.system.map((part) => part.cache_control),
|
||||||
|
...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
|
||||||
|
].filter((cache) => cache !== undefined)
|
||||||
|
expect(marked).toHaveLength(4)
|
||||||
|
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||||
|
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
OpenResponses,
|
OpenResponses,
|
||||||
} from "@opencode-ai/ai/protocols"
|
} from "@opencode-ai/ai/protocols"
|
||||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||||
|
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||||
|
|
||||||
describe("public exports", () => {
|
describe("public exports", () => {
|
||||||
test("root exposes app-facing runtime APIs", () => {
|
test("root exposes app-facing runtime APIs", () => {
|
||||||
@@ -28,6 +29,7 @@ describe("public exports", () => {
|
|||||||
expect(ImageInput.bytes).toBeFunction()
|
expect(ImageInput.bytes).toBeFunction()
|
||||||
expect(Provider.make).toBeFunction()
|
expect(Provider.make).toBeFunction()
|
||||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||||
|
expect(TestLLM.layer).toBeFunction()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("route barrel exposes route-authoring APIs", () => {
|
test("route barrel exposes route-authoring APIs", () => {
|
||||||
|
|||||||
+54
File diff suppressed because one or more lines are too long
@@ -58,6 +58,12 @@ describe("provider error classification", () => {
|
|||||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
|
expect(
|
||||||
|
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||||
|
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||||
|
})
|
||||||
|
|
||||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||||
expect(
|
expect(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { CacheHint, LLM } from "../../src"
|
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as Anthropic from "../../src/providers/anthropic"
|
import * as Anthropic from "../../src/providers/anthropic"
|
||||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||||
@@ -24,6 +24,39 @@ const cacheRequest = LLM.request({
|
|||||||
generation: { maxTokens: 16, temperature: 0 },
|
generation: { maxTokens: 16, temperature: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lookup = ToolDefinition.make({
|
||||||
|
name: "lookup",
|
||||||
|
description: "Look up a fixture value.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { index: { type: "number" } },
|
||||||
|
required: ["index"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const longToolTurn = [
|
||||||
|
Message.user("Run the fixture lookups."),
|
||||||
|
...Array.from({ length: 11 }, (_, index) => {
|
||||||
|
const id = `lookup_${index}`
|
||||||
|
return [
|
||||||
|
Message.assistant(ToolCallPart.make({ id, name: lookup.name, input: { index } })),
|
||||||
|
Message.tool({
|
||||||
|
id,
|
||||||
|
name: lookup.name,
|
||||||
|
result: `Fixture result ${index}. `.repeat(80),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}).flat(),
|
||||||
|
]
|
||||||
|
const longToolTurnRequest = LLM.request({
|
||||||
|
id: "recorded_anthropic_cache_long_tool_turn",
|
||||||
|
model,
|
||||||
|
system: LARGE_CACHEABLE_SYSTEM,
|
||||||
|
messages: longToolTurn,
|
||||||
|
tools: [lookup],
|
||||||
|
generation: { maxTokens: 16, temperature: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
const recorded = recordedTests({
|
const recorded = recordedTests({
|
||||||
prefix: "anthropic-messages-cache",
|
prefix: "anthropic-messages-cache",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
@@ -50,4 +83,28 @@ describe("Anthropic Messages cache recorded", () => {
|
|||||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recorded.effect.with("keeps a long tool turn inside the cache lookback", { tags: ["cache", "tool"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const first = yield* LLMClient.generate(longToolTurnRequest)
|
||||||
|
const firstRead = first.usage?.cacheReadInputTokens ?? 0
|
||||||
|
const firstWrite = first.usage?.cacheWriteInputTokens ?? 0
|
||||||
|
const firstCached = firstRead + firstWrite
|
||||||
|
// The prefix may already be warm when recording, so either a read or a
|
||||||
|
// write establishes that Anthropic recognized the cache boundary.
|
||||||
|
expect(firstCached).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const second = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(longToolTurnRequest, {
|
||||||
|
messages: [
|
||||||
|
...longToolTurn,
|
||||||
|
Message.assistant("The fixture lookups are complete."),
|
||||||
|
Message.user("Reply exactly: OK"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(firstCached)
|
||||||
|
expect(second.usage?.cacheWriteInputTokens ?? 0).toBeLessThan(firstCached)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||||
})
|
})
|
||||||
|
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
|
||||||
expect(response.message.content).toEqual([
|
expect(response.message.content).toEqual([
|
||||||
{ type: "text", text: "Hello!" },
|
{ type: "text", text: "Hello!" },
|
||||||
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||||
@@ -518,6 +519,199 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("requires message_stop before completing a streamed message", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||||
|
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.reason).toMatchObject({
|
||||||
|
_tag: "InvalidProviderOutput",
|
||||||
|
message: "Provider stream ended without a terminal finish event",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: {
|
||||||
|
input_tokens: 5,
|
||||||
|
cache_read_input_tokens: 2,
|
||||||
|
service_tier: "standard",
|
||||||
|
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||||
|
server_tool_use: { web_search_requests: 1, start_counter: 2 },
|
||||||
|
output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn" },
|
||||||
|
usage: {
|
||||||
|
output_tokens: 8,
|
||||||
|
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
|
||||||
|
output_tokens_details: { terminal_detail: "preserved" },
|
||||||
|
future_terminal: { requests: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.usage).toMatchObject({
|
||||||
|
inputTokens: 7,
|
||||||
|
outputTokens: 8,
|
||||||
|
reasoningTokens: 3,
|
||||||
|
totalTokens: 15,
|
||||||
|
providerMetadata: {
|
||||||
|
anthropic: {
|
||||||
|
input_tokens: 5,
|
||||||
|
cache_read_input_tokens: 2,
|
||||||
|
service_tier: "standard",
|
||||||
|
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||||
|
server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 },
|
||||||
|
output_tokens: 8,
|
||||||
|
output_tokens_details: {
|
||||||
|
thinking_tokens: 3,
|
||||||
|
start_detail: "preserved",
|
||||||
|
terminal_detail: "preserved",
|
||||||
|
},
|
||||||
|
future_terminal: { requests: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("round-trips omitted thinking carried only by a signature delta", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "thinking", thinking: "", signature: "" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
LLM.request({ model, messages: [response.message], cache: "none" }),
|
||||||
|
)
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retains a thinking signature supplied in content_block_start", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "thinking", thinking: "", signature: "sig_1" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||||
|
])
|
||||||
|
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||||
|
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retains complete tool input from content_block_start", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.toolCalls).toMatchObject([
|
||||||
|
{ id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retains empty text blocks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([{ type: "text", text: "" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
@@ -629,6 +823,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
delta: { stop_reason: "model_context_window_exceeded" },
|
delta: { stop_reason: "model_context_window_exceeded" },
|
||||||
usage: { output_tokens: 1 },
|
usage: { output_tokens: 1 },
|
||||||
},
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -646,6 +841,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
sseEvents(
|
sseEvents(
|
||||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -664,6 +860,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
|
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
|
||||||
{ type: "content_block_stop", index: 0 },
|
{ type: "content_block_stop", index: 0 },
|
||||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
)
|
)
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
@@ -849,6 +1046,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
|
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
|
||||||
{ type: "content_block_stop", index: 2 },
|
{ type: "content_block_stop", index: 2 },
|
||||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
)
|
)
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
@@ -912,6 +1110,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
},
|
},
|
||||||
{ type: "content_block_stop", index: 1 },
|
{ type: "content_block_stop", index: 1 },
|
||||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
)
|
)
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
|
|||||||
@@ -939,6 +939,7 @@ describe("Bedrock Converse route", () => {
|
|||||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model,
|
||||||
|
cache: "none",
|
||||||
messages: [
|
messages: [
|
||||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
|
||||||
Message.tool({
|
Message.tool({
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
prompt_tokens: 5,
|
prompt_tokens: 5,
|
||||||
completion_tokens: 2,
|
completion_tokens: 2,
|
||||||
total_tokens: 7,
|
total_tokens: 7,
|
||||||
prompt_tokens_details: { cached_tokens: 1 },
|
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||||
completion_tokens_details: { reasoning_tokens: 0 },
|
completion_tokens_details: { reasoning_tokens: 0 },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -558,8 +558,9 @@ describe("OpenAI Chat route", () => {
|
|||||||
const usage = new Usage({
|
const usage = new Usage({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 2,
|
||||||
nonCachedInputTokens: 4,
|
nonCachedInputTokens: 2,
|
||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
|
cacheWriteInputTokens: 2,
|
||||||
reasoningTokens: 0,
|
reasoningTokens: 0,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
@@ -567,7 +568,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
prompt_tokens: 5,
|
prompt_tokens: 5,
|
||||||
completion_tokens: 2,
|
completion_tokens: 2,
|
||||||
total_tokens: 7,
|
total_tokens: 7,
|
||||||
prompt_tokens_details: { cached_tokens: 1 },
|
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||||
completion_tokens_details: { reasoning_tokens: 0 },
|
completion_tokens_details: { reasoning_tokens: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMEvent } from "../../src"
|
import { LLM, LLMEvent, Message } from "../../src"
|
||||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||||
import { OpenAI } from "../../src/providers"
|
import { OpenAI } from "../../src/providers"
|
||||||
import { OpenResponses } from "../../src/protocols/open-responses"
|
import { OpenResponses } from "../../src/protocols/open-responses"
|
||||||
@@ -70,6 +70,31 @@ describe("Open Responses-compatible route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = configure({
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: "https://responses.example.test/v1",
|
||||||
|
}).model("example-model")
|
||||||
|
const prepared = yield* LLMClient.prepare(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant({
|
||||||
|
type: "text",
|
||||||
|
text: "Unclassified.",
|
||||||
|
providerMetadata: { openresponses: { phase: null } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("reads standard options from the Open Responses namespace", () =>
|
it.effect("reads standard options from the Open Responses namespace", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = configure({
|
const model = configure({
|
||||||
|
|||||||
@@ -832,7 +832,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
input_tokens: 5,
|
input_tokens: 5,
|
||||||
output_tokens: 2,
|
output_tokens: 2,
|
||||||
total_tokens: 7,
|
total_tokens: 7,
|
||||||
input_tokens_details: { cached_tokens: 1 },
|
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||||
output_tokens_details: { reasoning_tokens: 0 },
|
output_tokens_details: { reasoning_tokens: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -842,8 +842,9 @@ describe("OpenAI Responses route", () => {
|
|||||||
const usage = new Usage({
|
const usage = new Usage({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 2,
|
||||||
nonCachedInputTokens: 4,
|
nonCachedInputTokens: 2,
|
||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
|
cacheWriteInputTokens: 2,
|
||||||
reasoningTokens: 0,
|
reasoningTokens: 0,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
@@ -851,7 +852,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
input_tokens: 5,
|
input_tokens: 5,
|
||||||
output_tokens: 2,
|
output_tokens: 2,
|
||||||
total_tokens: 7,
|
total_tokens: 7,
|
||||||
input_tokens_details: { cached_tokens: 1 },
|
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||||
output_tokens_details: { reasoning_tokens: 0 },
|
output_tokens_details: { reasoning_tokens: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -881,6 +882,121 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves and replays assistant message phases", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_commentary" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
|
||||||
|
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Checking.",
|
||||||
|
providerMetadata: { openai: { phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Finished.",
|
||||||
|
providerMetadata: { openai: { phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Unclassified.",
|
||||||
|
providerMetadata: { openai: { phase: null } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Checking." }],
|
||||||
|
phase: "commentary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Finished." }],
|
||||||
|
phase: "final_answer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Unclassified." }],
|
||||||
|
phase: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects output text events without the spec-required item id", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.output_text.delta", delta: "orphaned" },
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects reasoning events without the spec-required item id", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = [
|
||||||
|
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_text.done" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
expect(error.message).toContain(`${event.type} is missing item_id`)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps incomplete response reasons", () =>
|
it.effect("maps incomplete response reasons", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const generate = (incompleteDetails: object) =>
|
const generate = (incompleteDetails: object) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Content } from "@opencode-ai/schema/tool"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import {
|
import {
|
||||||
GenerationOptions,
|
GenerationOptions,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
ToolChoice,
|
ToolChoice,
|
||||||
ToolContent,
|
|
||||||
ToolOutput,
|
ToolOutput,
|
||||||
toDefinitions,
|
toDefinitions,
|
||||||
} from "../src"
|
} from "../src"
|
||||||
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
|
|||||||
|
|
||||||
it.effect("models canonical tool files with URIs", () =>
|
it.effect("models canonical tool files with URIs", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const decode = Schema.decodeUnknownSync(ToolContent)
|
const decode = Schema.decodeUnknownSync(Content)
|
||||||
|
|
||||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||||
type: "file",
|
type: "file",
|
||||||
@@ -539,6 +539,7 @@ describe("LLMClient tools", () => {
|
|||||||
},
|
},
|
||||||
{ type: "content_block_stop", index: 1 },
|
{ type: "content_block_stop", index: 1 },
|
||||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
|
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
)
|
)
|
||||||
: sseEvents(
|
: sseEvents(
|
||||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
@@ -546,6 +547,7 @@ describe("LLMClient tools", () => {
|
|||||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
|
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
|
||||||
{ type: "content_block_stop", index: 0 },
|
{ type: "content_block_stop", index: 0 },
|
||||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
),
|
),
|
||||||
{ headers: { "content-type": "text/event-stream" } },
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
)
|
)
|
||||||
@@ -801,6 +803,7 @@ describe("LLMClient tools", () => {
|
|||||||
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
|
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
|
||||||
{ type: "content_block_stop", index: 2 },
|
{ type: "content_block_stop", index: 2 },
|
||||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
),
|
),
|
||||||
{ headers: { "content-type": "text/event-stream" } },
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# V1 API Migration Checklist
|
||||||
|
|
||||||
|
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||||
|
- `src/context/server-sdk.tsx`
|
||||||
|
- [x] Reduce current granular session and message events into the existing app projections.
|
||||||
|
- `src/context/server-session-v2-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- `src/context/notification.tsx`
|
||||||
|
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||||
|
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [x] Adapt current permission and question events to the existing request model.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- [x] Consume current file watcher events.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- [x] Consume current VCS events.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Consume current `pty.exited` events.
|
||||||
|
- `src/context/terminal.tsx`
|
||||||
|
- [ ] Migrate LSP and reference events.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate session listing from `GET /session`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- `src/context/directory-sync.ts`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||||
|
- `src/components/titlebar.tsx`
|
||||||
|
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||||
|
- `src/context/directory-sync.ts`
|
||||||
|
- `src/context/layout.tsx`
|
||||||
|
- `src/pages/home.tsx`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- `src/components/titlebar-tab-nav.tsx`
|
||||||
|
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||||
|
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||||
|
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||||
|
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||||
|
- `src/components/dialog-fork.tsx`
|
||||||
|
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- Blocked: the current API has no sharing contract or implementation.
|
||||||
|
|
||||||
|
## Session Compatibility Fallbacks
|
||||||
|
|
||||||
|
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||||
|
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
|
||||||
|
## Filesystem
|
||||||
|
|
||||||
|
- [ ] Migrate file listing from `GET /file`.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- [ ] Migrate file reads from `GET /file/content`.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- `src/pages/session/review-tab.tsx`
|
||||||
|
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||||
|
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/components/dialog-select-directory.tsx`
|
||||||
|
- `src/components/dialog-select-directory-v2.tsx`
|
||||||
|
|
||||||
|
## Projects And Worktrees
|
||||||
|
|
||||||
|
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||||
|
- `src/context/layout.tsx`
|
||||||
|
- `src/components/edit-project.ts`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||||
|
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
|
||||||
|
## VCS
|
||||||
|
|
||||||
|
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
|
||||||
|
## Configuration And Authentication
|
||||||
|
|
||||||
|
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||||
|
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- `src/components/dialog-custom-provider.tsx`
|
||||||
|
- `src/components/settings-providers.tsx`
|
||||||
|
- `src/components/settings-v2/providers.tsx`
|
||||||
|
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- `src/components/settings-providers.tsx`
|
||||||
|
- `src/components/settings-v2/providers.tsx`
|
||||||
|
|
||||||
|
## Permissions And Questions
|
||||||
|
|
||||||
|
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- `src/pages/session/composer/session-composer-state.ts`
|
||||||
|
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||||
|
- `src/pages/session/composer/session-question-dock.tsx`
|
||||||
|
|
||||||
|
## Commands, MCP, LSP, And References
|
||||||
|
|
||||||
|
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [ ] Migrate LSP status from `GET /lsp`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||||
|
- `src/components/command-palette.ts`
|
||||||
|
- `src/components/dialog-command-palette-v2.tsx`
|
||||||
|
|
||||||
|
## PTY And Terminal
|
||||||
|
|
||||||
|
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||||
|
- `src/context/terminal.tsx`
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||||
|
- `src/components/settings-general.tsx`
|
||||||
|
- `src/components/settings-v2/general.tsx`
|
||||||
|
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
|
||||||
|
## Legacy Types And Adapters
|
||||||
|
|
||||||
|
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||||
|
|
||||||
|
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||||
|
- `src/utils/session.ts`
|
||||||
|
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||||
|
- `src/utils/session-message.ts`
|
||||||
|
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||||
|
- `src/context/global-sync/utils.ts`
|
||||||
|
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||||
|
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||||
|
- `package.json`
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||||
|
- `e2e/utils/mock-server.ts`
|
||||||
|
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||||
|
- `e2e/utils/sse-transport.ts`
|
||||||
|
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||||
|
- `e2e/performance/timeline-stability/fixture.ts`
|
||||||
|
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||||
@@ -20,7 +20,7 @@ const profiles = [
|
|||||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||||
{
|
{
|
||||||
name: "multi patch",
|
name: "multi patch",
|
||||||
tool: "patch",
|
tool: "apply_patch",
|
||||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
userMessage(),
|
userMessage(),
|
||||||
assistantMessage(
|
assistantMessage(
|
||||||
[
|
[
|
||||||
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||||
textPart(followingID, "Following incremental patch"),
|
textPart(followingID, "Following incremental patch"),
|
||||||
],
|
],
|
||||||
{ completed: false },
|
{ completed: false },
|
||||||
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
partUpdated(
|
partUpdated(
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"running",
|
"running",
|
||||||
{ files: [first.filePath, second.filePath] },
|
{ files: [first.filePath, second.filePath] },
|
||||||
{ metadata: { files: [first, second] } },
|
{ metadata: { files: [first, second] } },
|
||||||
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
partUpdated(
|
partUpdated(
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||||
{ metadata: { files: [first, second, third] } },
|
{ metadata: { files: [first, second, third] } },
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ test.describe("timeline tool state stability", () => {
|
|||||||
}
|
}
|
||||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||||
const questionID = "prt_state_question"
|
const questionID = "prt_state_question"
|
||||||
|
const todoID = "prt_state_todo"
|
||||||
const initial = [
|
const initial = [
|
||||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||||
toolPart(questionID, "question", "pending", questionInput()),
|
toolPart(questionID, "question", "pending", questionInput()),
|
||||||
|
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||||
textPart("prt_state_following", "Following lightweight tools"),
|
textPart("prt_state_following", "Following lightweight tools"),
|
||||||
]
|
]
|
||||||
const childID = "ses_timeline_child"
|
const childID = "ses_timeline_child"
|
||||||
@@ -47,6 +49,7 @@ test.describe("timeline tool state stability", () => {
|
|||||||
await timeline.send(status("busy"), 120)
|
await timeline.send(status("busy"), 120)
|
||||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||||
|
|
||||||
const regionIDs = [
|
const regionIDs = [
|
||||||
"prt_state_webfetch",
|
"prt_state_webfetch",
|
||||||
@@ -102,6 +105,7 @@ test.describe("timeline tool state stability", () => {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
|||||||
const messages = [user, ...assistants]
|
const messages = [user, ...assistants]
|
||||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
const lastID = userID
|
const lastID = userID
|
||||||
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
const lastAssistant = assistants.at(-1)!
|
||||||
|
const lastPart = lastAssistant.parts.at(-1)!
|
||||||
|
const lastPartID =
|
||||||
|
lastPart.type === "tool"
|
||||||
|
? lastPart.id
|
||||||
|
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||||
|
|
||||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(180_000)
|
benchmark.setTimeout(180_000)
|
||||||
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||||
)
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
data: {
|
||||||
|
...target,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
location: { directory: target.directory },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: target,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
parent: requests.filter((request) => request.type === "parent").length,
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
}
|
}
|
||||||
if (mode === "candidate") {
|
if (mode === "candidate") {
|
||||||
expect(requestCounts.parent).toBe(1)
|
expect(requestCounts.parent).toBe(0)
|
||||||
expect(historyGates).toBe(1)
|
expect(historyGates).toBe(0)
|
||||||
}
|
}
|
||||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
|||||||
messageID: assistantID,
|
messageID: assistantID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: `call_0000_${suffix}_patch`,
|
callID: `call_0000_${suffix}_patch`,
|
||||||
tool: "patch",
|
tool: "apply_patch",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { patchText: realisticPatch(index) },
|
input: { patchText: realisticPatch(index) },
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function toolPart(
|
|||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
metadataOverride ??
|
metadataOverride ??
|
||||||
(tool === "patch"
|
(tool === "apply_patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
|||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0
|
...(index % 7 === 0
|
||||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||||
@@ -269,6 +269,7 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
|||||||
]).flat()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
|||||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
@@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route)
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
@@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: current.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/OpenFileExpand"
|
||||||
|
const projectID = "proj_open_file_expand"
|
||||||
|
const sessionID = "ses_open_file_expand"
|
||||||
|
const title = "Open file expand"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-expand",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path === "frontend\\" || path === "frontend") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "app.ts",
|
||||||
|
path: "frontend\\app.ts",
|
||||||
|
absolute: `${directory}/frontend/app.ts`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "frontend",
|
||||||
|
path: "frontend\\",
|
||||||
|
absolute: `${directory}/frontend`,
|
||||||
|
type: "directory" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
path: "README.md",
|
||||||
|
absolute: `${directory}/README.md`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
|
||||||
|
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||||
|
await expect(frontendRow).toBeVisible()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await frontendRow.click()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
|
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||||
|
await expect(appRow).toBeVisible()
|
||||||
|
await appRow.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
await page.keyboard.press("Control+,")
|
||||||
|
|
||||||
const dialog = page.locator(".settings-v2-dialog")
|
const dialog = page.locator(".settings-v2-dialog")
|
||||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
|||||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
await page.keyboard.press("Control+,")
|
||||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||||
@@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session/status") return json(route, {})
|
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
return json(route, { data: [] })
|
||||||
|
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||||
|
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
||||||
|
return json(route, { location: { directory }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp/resource")
|
||||||
|
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
|
||||||
|
if (url.pathname === "/api/project") {
|
||||||
|
return json(route, [
|
||||||
|
{
|
||||||
|
id: remote ? sessionB.projectID : "project-server-a",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/project/current")
|
||||||
|
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||||
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||||
|
return json(route, { data: [], cursor: {} })
|
||||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
if (current) return json(route, current)
|
if (current) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
@@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
directory,
|
directory,
|
||||||
home: directory,
|
home: directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||||
|
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -57,15 +58,19 @@ async function mockServers(page: Page) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route, url.pathname === "/api/event")
|
||||||
if (url.pathname === "/session/status")
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/api/session/active")
|
||||||
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
@@ -90,7 +95,20 @@ async function mockServers(page: Page) {
|
|||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: current.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route) {
|
function sse(route: Route, current: boolean) {
|
||||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
|||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 700, height: 900 })
|
await page.setViewportSize({ width: 700, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_review_line_comment_regression",
|
id: "proj_review_line_comment_regression",
|
||||||
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
|
|||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
|
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
||||||
await page.getByRole("tab", { name: "Changes" }).click()
|
await page.getByRole("tab", { name: "Changes" }).click()
|
||||||
expect(await (await diffResponse).json()).toHaveLength(1)
|
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
await panel.locator("#session-side-panel-review-tab").click()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
await page.keyboard.press("Control+w")
|
await page.keyboard.press("Control+w")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
|||||||
|
|
||||||
async function selectMode(page: Page, current: string, next: string) {
|
async function selectMode(page: Page, current: string, next: string) {
|
||||||
await page.getByRole("button", { name: current }).click()
|
await page.getByRole("button", { name: current }).click()
|
||||||
await page.getByRole("option", { name: next }).click()
|
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFile(page: Page, file: string) {
|
async function selectFile(page: Page, file: string) {
|
||||||
@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const branchDiffs = [
|
|||||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||||
|
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
||||||
let detailVersion = 1
|
let detailVersion = 1
|
||||||
let detailFailures = 1
|
let detailFailures = 1
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
sessionStatus: () => sessionStatus,
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
@@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
body: JSON.stringify({
|
||||||
|
branch: "review-pane-performance",
|
||||||
|
default_branch: "dev",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/vcs/diff**", (route) => {
|
await page.route("**/vcs/diff**", (route) => {
|
||||||
@@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/pty/pty_review_terminal", (route) =>
|
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||||
await expect(preview).toContainText("after-1")
|
await expect(preview).toContainText("after-1")
|
||||||
detailVersion = 2
|
detailVersion = 2
|
||||||
|
sessionStatus[sessionID] = { type: "busy" }
|
||||||
events.push(statusEvent("busy"))
|
events.push(statusEvent("busy"))
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
const refreshedDiff = page.waitForRequest((request) => {
|
const refreshedDiff = page.waitForRequest((request) => {
|
||||||
@@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
sessionStatus[sessionID] = { type: "idle" }
|
||||||
events.push(statusEvent("idle"))
|
events.push(statusEvent("idle"))
|
||||||
await refreshedDiff
|
await refreshedDiff
|
||||||
await expect(preview).toContainText("after-2")
|
await expect(preview).toContainText("after-2")
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
|||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/path?*", async (route) => {
|
await page.route("**/api/path?*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||||
|
rejectRequests.push(request.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
|
|
||||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
(request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
const request = await reply
|
const request = await reply
|
||||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
@@ -170,6 +173,7 @@ async function mockServer(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
|||||||
assistantMessage([
|
assistantMessage([
|
||||||
toolPart(
|
toolPart(
|
||||||
id,
|
id,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: ["src/a.ts"] },
|
{ files: ["src/a.ts"] },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
|||||||
assistantMessage([
|
assistantMessage([
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: files.map((file) => file.filePath) },
|
{ files: files.map((file) => file.filePath) },
|
||||||
{ metadata: { files } },
|
{ metadata: { files } },
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ test.describe("session timeline projection", () => {
|
|||||||
editPart("prt_edit"),
|
editPart("prt_edit"),
|
||||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||||
patchPart("prt_patch"),
|
patchPart("prt_patch"),
|
||||||
|
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||||
toolPart(
|
toolPart(
|
||||||
"prt_question",
|
"prt_question",
|
||||||
"question",
|
"question",
|
||||||
@@ -64,6 +65,7 @@ test.describe("session timeline projection", () => {
|
|||||||
]) {
|
]) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||||
}
|
}
|
||||||
|
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||||
@@ -247,7 +249,7 @@ function editPart(id: string) {
|
|||||||
function patchPart(id: string) {
|
function patchPart(id: string) {
|
||||||
return toolPart(
|
return toolPart(
|
||||||
id,
|
id,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: ["src/a.ts", "src/b.ts"] },
|
{ files: ["src/a.ts", "src/b.ts"] },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "../performance/timeline-stability/fixture"
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||||
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||||
const parts = ordinary.map((tool, index) =>
|
const parts = ordinary.map((tool, index) =>
|
||||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||||
)
|
)
|
||||||
@@ -17,11 +17,13 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
|||||||
error: "The user dismissed this question",
|
error: "The user dismissed this question",
|
||||||
}),
|
}),
|
||||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||||
|
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||||
)
|
)
|
||||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||||
|
|
||||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||||
|
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||||
for (let index = 0; index < ordinary.length; index++) {
|
for (let index = 0; index < ordinary.length; index++) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,7 @@ function questionInput() {
|
|||||||
function errorInput(tool: string) {
|
function errorInput(tool: string) {
|
||||||
if (tool === "bash") return { command: "exit 1" }
|
if (tool === "bash") return { command: "exit 1" }
|
||||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||||
if (tool === "patch") return { files: ["src/error.ts"] }
|
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||||
if (tool === "webfetch") return { url: "https://example.com" }
|
if (tool === "webfetch") return { url: "https://example.com" }
|
||||||
if (tool === "websearch") return { query: "failure" }
|
if (tool === "websearch") return { query: "failure" }
|
||||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||||
|
const projectID = "proj_todo_dock_navigation"
|
||||||
|
const sourceID = "ses_todo_dock_source"
|
||||||
|
const otherID = "ses_todo_dock_other"
|
||||||
|
const sourceTitle = "Todo dock animation"
|
||||||
|
const otherTitle = "Separate session"
|
||||||
|
|
||||||
|
const activeTodos = [
|
||||||
|
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
directory: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||||
|
|
||||||
|
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000)
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
|
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "todo-dock-navigation",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
|
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
|
sessionStatus: () => sessionStatus,
|
||||||
|
todos: (sessionID) => todos[sessionID] ?? [],
|
||||||
|
})
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sourceID))
|
||||||
|
await expectSessionTitle(page, sourceTitle)
|
||||||
|
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
sessionStatus[sourceID] = { type: "busy" }
|
||||||
|
events.push(statusEvent(sourceID, "busy"))
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
|
||||||
|
await page.waitForTimeout(700)
|
||||||
|
const opening = sampleDock(page, 1_000)
|
||||||
|
todos[sourceID] = activeTodos
|
||||||
|
events.push(todoEvent(sourceID, activeTodos))
|
||||||
|
await expect(dock).toBeVisible()
|
||||||
|
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||||
|
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||||
|
|
||||||
|
await switchSession(page, otherID, otherTitle)
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
const returningOpen = sampleDock(page, 700)
|
||||||
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
|
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||||
|
expect(openSamples.length).toBeGreaterThan(0)
|
||||||
|
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||||
|
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||||
|
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||||
|
|
||||||
|
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||||
|
const closing = sampleDock(page, 1_000)
|
||||||
|
todos[sourceID] = completedTodos
|
||||||
|
events.push(todoEvent(sourceID, completedTodos))
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||||
|
todos[sourceID] = []
|
||||||
|
events.push(todoEvent(sourceID, []))
|
||||||
|
|
||||||
|
await switchSession(page, otherID, otherTitle)
|
||||||
|
const returningEmpty = sampleDock(page, 700)
|
||||||
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function session(id: string, title: string, created: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created, updated: created },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configurePage(page: Page) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, dirBase64, server, sessionIDs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHref(sessionID: string) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||||
|
const href = sessionHref(sessionID)
|
||||||
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleDock(page: Page, duration: number) {
|
||||||
|
return page.evaluate(async (duration) => {
|
||||||
|
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||||
|
const start = performance.now()
|
||||||
|
while (performance.now() - start < duration) {
|
||||||
|
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||||
|
const clip = dock?.parentElement?.parentElement
|
||||||
|
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||||
|
samples.push({
|
||||||
|
present: !!dock,
|
||||||
|
height: clip?.getBoundingClientRect().height ?? 0,
|
||||||
|
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||||
|
})
|
||||||
|
await new Promise(requestAnimationFrame)
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
|||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
eventRetry: events ? 16 : undefined,
|
||||||
})
|
})
|
||||||
// The child session resolves via /session/:id but is absent from the /session list,
|
// The child session resolves by ID but is absent from the session list,
|
||||||
// matching a subagent session that has not been loaded into the list cache yet.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
await page.route(
|
||||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||||
(route) =>
|
(route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
body: JSON.stringify({
|
||||||
|
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||||
|
cursor: {},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
|
const sessionC = session("ses_tab_c", "Tab C session")
|
||||||
|
const unresolvedSessionID = "ses_tab_unresolved"
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
@@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
|||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||||
|
await mockServer(page)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ server, sessionA, unresolved, sessionC }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
{ type: "session", server, sessionId: sessionA },
|
||||||
|
{ type: "session", server, sessionId: unresolved },
|
||||||
|
{ type: "session", server, sessionId: sessionC },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||||
|
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
||||||
|
await page.goto(hrefA)
|
||||||
|
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
||||||
|
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Alt+ArrowRight")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
})
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -52,22 +83,29 @@ function session(id: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB]
|
const sessions = [sessionA, sessionB, sessionC]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
|
return new Promise(() => {})
|
||||||
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||||
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||||
|
return json(route, { data: [], cursor: {} })
|
||||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||||
if (byId) return json(route, byId)
|
if (byId) return json(route, byId)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
@@ -89,7 +127,20 @@ async function mockServer(page: Page) {
|
|||||||
directory: sessionA.directory,
|
directory: sessionA.directory,
|
||||||
home: sessionA.directory,
|
home: sessionA.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: sessionA.directory,
|
||||||
|
config: sessionA.directory,
|
||||||
|
worktree: sessionA.directory,
|
||||||
|
directory: sessionA.directory,
|
||||||
|
home: sessionA.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: sessionA.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) => {
|
||||||
|
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
})
|
})
|
||||||
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
|||||||
const ghostty = Promise.withResolvers<void>()
|
const ghostty = Promise.withResolvers<void>()
|
||||||
const release = Promise.withResolvers<void>()
|
const release = Promise.withResolvers<void>()
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty*", (route) => {
|
||||||
created.count += 1
|
created.count += 1
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route(/ghostty-web/, async (route) => {
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
@@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
|||||||
|
|
||||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty*", (route) => {
|
||||||
created.count += 1
|
created.count += 1
|
||||||
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(next),
|
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
|
|||||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo(id: string, title: string) {
|
||||||
|
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
|
|||||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_hidden_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_hidden_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
|||||||
const terminal = page.locator('[data-component="terminal"]')
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
await expect(terminal).toBeVisible()
|
await expect(terminal).toBeVisible()
|
||||||
await expect.poll(() => connections.length).toBe(1)
|
await expect.poll(() => connections.length).toBe(1)
|
||||||
|
const connection = new URL(connections[0]!)
|
||||||
|
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||||
|
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -85,26 +90,33 @@ async function setup(page: Page) {
|
|||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
|
||||||
|
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
const connections: string[] = []
|
const connections: string[] = []
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||||
connections.push(ws.url())
|
connections.push(ws.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
|
|||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo() {
|
||||||
|
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ function toolPart(
|
|||||||
outputLength = 160,
|
outputLength = 160,
|
||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
tool === "patch"
|
tool === "apply_patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
|
|||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||||
@@ -229,6 +229,7 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
|||||||
]).flat()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/NewProject"
|
||||||
|
|
||||||
|
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||||
|
let connectedGo = false
|
||||||
|
let pendingGo = false
|
||||||
|
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: "proj_model_selection_flow",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "NewProject",
|
||||||
|
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: () => ({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"free-model": {
|
||||||
|
id: "free-model",
|
||||||
|
name: "Free Model",
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode-go",
|
||||||
|
name: "OpenCode Go",
|
||||||
|
models: {
|
||||||
|
"go-model-1": {
|
||||||
|
id: "go-model-1",
|
||||||
|
name: "Go Model 1",
|
||||||
|
cost: { input: 1, output: 1 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "free-model" },
|
||||||
|
}),
|
||||||
|
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||||
|
onConnectKey: (input) => {
|
||||||
|
connections.push(input)
|
||||||
|
if (input.integrationID === "opencode-go") pendingGo = true
|
||||||
|
},
|
||||||
|
onInstanceDispose: () => {
|
||||||
|
if (pendingGo) connectedGo = true
|
||||||
|
},
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
fileList: (path) =>
|
||||||
|
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||||
|
findFiles: () => ["NewProject"],
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/")
|
||||||
|
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||||
|
await expectAppVisible(addProject)
|
||||||
|
await addProject.click()
|
||||||
|
await page.locator("[data-directory-path]").click()
|
||||||
|
|
||||||
|
await page.locator('[data-action="home-new-session"]').click()
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||||
|
|
||||||
|
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||||
|
await modelControl.click()
|
||||||
|
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||||
|
|
||||||
|
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||||
|
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||||
|
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||||
|
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||||
|
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||||
|
|
||||||
|
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||||
|
await modelControl.click()
|
||||||
|
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||||
|
await expect(goModel).toBeVisible()
|
||||||
|
await goModel.click()
|
||||||
|
|
||||||
|
await expect(modelControl).toContainText("Go Model 1")
|
||||||
|
})
|
||||||
@@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
|
|||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
protocol?: "v1" | "v2"
|
protocol?: "v1" | "v2"
|
||||||
provider: unknown
|
provider: unknown | (() => unknown)
|
||||||
|
integrationMethods?: Record<string, unknown[]>
|
||||||
|
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||||
|
onInstanceDispose?: () => void
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
@@ -18,19 +21,19 @@ export interface MockServerConfig {
|
|||||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
|
todos?: (sessionID: string) => unknown[]
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: unknown
|
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/provider": config.provider,
|
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
config: config.directory,
|
config: config.directory,
|
||||||
@@ -60,7 +63,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
route,
|
route,
|
||||||
path === "/api/event"
|
path === "/api/event"
|
||||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||||
: events,
|
: [
|
||||||
|
...(path === "/global/event"
|
||||||
|
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||||
|
: []),
|
||||||
|
...(events ?? []),
|
||||||
|
],
|
||||||
config.eventRetry,
|
config.eventRetry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -69,11 +77,27 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (path === "/api/health" && config.protocol === "v2")
|
if (path === "/api/health" && config.protocol === "v2")
|
||||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
|
if (path === "/provider")
|
||||||
|
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||||
|
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||||
|
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||||
|
if (legacyAuth && route.request().method() === "PUT") {
|
||||||
|
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||||
|
config.onInstanceDispose?.()
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
if (path === "/session/status")
|
||||||
|
return json(
|
||||||
|
route,
|
||||||
|
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||||
|
)
|
||||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (path === "/file" && config.fileList)
|
if (path === "/file" && config.fileList)
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
@@ -120,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
location: location(config),
|
location: location(config),
|
||||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||||
})
|
})
|
||||||
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||||
|
if (integrationConnect && route.request().method() === "POST") {
|
||||||
|
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
if (path === "/api/project") return json(route, [config.project])
|
if (path === "/api/project") return json(route, [config.project])
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
@@ -199,6 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||||
route.request().method() === "POST"
|
route.request().method() === "POST"
|
||||||
@@ -237,6 +270,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return json(route, message)
|
return json(route, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||||
|
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||||
|
|||||||
@@ -197,6 +197,14 @@ export async function installSseTransport<T>(
|
|||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
)
|
)
|
||||||
|
if (url.pathname === "/global/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
frame({
|
||||||
|
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.4",
|
"version": "1.18.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||||
"@opencode-ai/session-ui": "workspace:*",
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
|||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome } from "@/pages/home"
|
||||||
|
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
@@ -236,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (settings.general.newLayoutDesigns()) return
|
||||||
|
const current = server.current
|
||||||
|
if (!current) return
|
||||||
|
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||||
|
if (protocol !== "v2") return
|
||||||
|
const next = global.servers.list().find((s) => {
|
||||||
|
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||||
|
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||||
|
})
|
||||||
|
if (!next) return
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||||
@@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs"
|
|||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
server: ServerConnection.key(serverSDK.server),
|
server: ServerConnection.key(serverSDK.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) =>
|
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
@@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
|
|||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
opened: () => LocalProject[]
|
opened: () => LocalProject[]
|
||||||
stored: () => Project[]
|
stored: () => Project[]
|
||||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
@@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
|
|||||||
return props
|
return props
|
||||||
.load(search, current.signal)
|
.load(search, current.signal)
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
(result.data ?? [])
|
result.data
|
||||||
|
.map(normalizeSessionInfo)
|
||||||
.filter((session) => !session.time.archived)
|
.filter((session) => !session.time.archived)
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
const project =
|
const project =
|
||||||
@@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
|
|||||||
id: `session:${props.server}:${session.id}`,
|
id: `session:${props.server}:${session.id}`,
|
||||||
type: "session" as const,
|
type: "session" as const,
|
||||||
title: session.title || props.untitled(),
|
title: session.title || props.untitled(),
|
||||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
description: project ? displayName(project) : getFilename(session.directory),
|
||||||
category: props.category(),
|
category: props.category(),
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
|||||||
server: ServerConnection.key(props.server),
|
server: ServerConnection.key(props.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) =>
|
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
@@ -35,8 +36,10 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
@@ -154,7 +157,7 @@ function ProviderPicker(props: {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
if (settings.general.newLayoutDesigns())
|
if (settings.general.newLayoutDesigns())
|
||||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
@@ -226,10 +229,8 @@ function ProviderPickerV2(props: {
|
|||||||
onSelect: (provider: string) => void
|
onSelect: (provider: string) => void
|
||||||
onPrepare?: () => void
|
onPrepare?: () => void
|
||||||
}) {
|
}) {
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
active: undefined as string | undefined,
|
active: undefined as string | undefined,
|
||||||
@@ -266,19 +267,7 @@ function ProviderPickerV2(props: {
|
|||||||
|
|
||||||
const connect = (provider: string) => {
|
const connect = (provider: string) => {
|
||||||
props.onPrepare?.()
|
props.onPrepare?.()
|
||||||
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
|
||||||
props.onSelect(provider)
|
props.onSelect(provider)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (store.connecting) return
|
|
||||||
setStore("connecting", provider)
|
|
||||||
void serverSDK()
|
|
||||||
.client.provider.auth()
|
|
||||||
.then((response) => {
|
|
||||||
serverSync().set("provider_auth", response.data ?? {})
|
|
||||||
props.onSelect(provider)
|
|
||||||
})
|
|
||||||
.catch(() => props.onSelect(provider))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (event: KeyboardEvent, direction: number) => {
|
const move = (event: KeyboardEvent, direction: number) => {
|
||||||
@@ -395,10 +384,16 @@ function ProviderConnection(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
|
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||||
|
const location = () => {
|
||||||
|
const value = directory()
|
||||||
|
return value ? { directory: value } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
@@ -413,38 +408,34 @@ function ProviderConnection(props: {
|
|||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||||
{
|
{
|
||||||
type: "api" as const,
|
type: "key" as const,
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const [auth] = createResource(
|
const [integration] = createResource(
|
||||||
() => props.provider,
|
() => ({ provider: props.provider, directory: directory() }),
|
||||||
async () => {
|
(input) =>
|
||||||
const cached = serverSync().data.provider_auth[props.provider]
|
serverSDK()
|
||||||
if (cached) return cached
|
.api.integration.get({
|
||||||
const res = await serverSDK().client.provider.auth()
|
integrationID: input.provider,
|
||||||
if (!alive.value) return fallback()
|
location: input.directory ? { directory: input.directory } : undefined,
|
||||||
serverSync().set("provider_auth", res.data ?? {})
|
})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
.then((result) => result.data),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => integration.loading)
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo<ConnectMethod[]>(() => {
|
||||||
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
const values = integration.latest?.methods.filter(
|
||||||
const directMethod =
|
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||||
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
)
|
||||||
|
return values?.length ? values : fallback()
|
||||||
|
})
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: directMethod as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: (directMethod === undefined ? "pending" : undefined) as
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
| undefined
|
|
||||||
| "pending"
|
|
||||||
| "complete"
|
|
||||||
| "error"
|
|
||||||
| "prompt",
|
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,7 +445,7 @@ function ProviderConnection(props: {
|
|||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
@@ -508,7 +499,7 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +509,7 @@ function ProviderConnection(props: {
|
|||||||
const hint = suffix?.[1]
|
const hint = suffix?.[1]
|
||||||
return {
|
return {
|
||||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
|
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,46 +540,22 @@ function ProviderConnection(props: {
|
|||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
if (method.type === "api" && method.prompts?.length) {
|
|
||||||
if (!inputs) {
|
|
||||||
dispatch({ type: "auth.prompt" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({ type: "auth.inputs", inputs })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.client.provider.oauth.authorize(
|
.api.integration.oauth.connect({
|
||||||
{
|
integrationID: props.provider,
|
||||||
providerID: props.provider,
|
methodID: method.id,
|
||||||
method: index,
|
inputs: inputs ?? {},
|
||||||
inputs,
|
location: location(),
|
||||||
},
|
})
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
const elapsed = Date.now() - start
|
dispatch({ type: "auth.complete", authorization: x.data })
|
||||||
const delay = 1000 - elapsed
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
timer.current = undefined
|
|
||||||
if (!alive.value) return
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
|
||||||
}, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
@@ -603,9 +570,9 @@ function ProviderConnection(props: {
|
|||||||
index: 0,
|
index: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
const prompts = createMemo(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
return value?.prompts ?? []
|
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
@@ -636,10 +603,6 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (method()?.type === "api") {
|
|
||||||
dispatch({ type: "auth.inputs", inputs: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,7 +704,9 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSDK().client.global.dispose()
|
await serverSync()
|
||||||
|
.refreshProviders()
|
||||||
|
.catch(() => undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -805,7 +770,7 @@ function ProviderConnection(props: {
|
|||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label ?? m?.type}
|
||||||
onSelect={async (selected, index) => {
|
onSelect={async (selected, index) => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
void selectMethod(index)
|
void selectMethod(index)
|
||||||
@@ -851,13 +816,10 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK().api.integration.connect.key({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
auth: {
|
location: location(),
|
||||||
type: "api",
|
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
@@ -891,6 +853,7 @@ function ProviderConnection(props: {
|
|||||||
ref={apiKey}
|
ref={apiKey}
|
||||||
class="!w-full"
|
class="!w-full"
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
|
data-input="provider-api-key"
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
value={formStore.value}
|
value={formStore.value}
|
||||||
invalid={formStore.error !== undefined}
|
invalid={formStore.error !== undefined}
|
||||||
@@ -907,7 +870,7 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<ButtonV2 type="submit" variant="contrast">
|
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||||
{language.t("common.continue")}
|
{language.t("common.continue")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</form>
|
</form>
|
||||||
@@ -984,12 +947,13 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.complete({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
attemptID: store.authorization!.attemptID,
|
||||||
|
location: location(),
|
||||||
code,
|
code,
|
||||||
})
|
})
|
||||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
.then(() => ({ ok: true as const }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
@@ -1076,25 +1040,37 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
const poll = async () => {
|
||||||
|
const authorization = store.authorization
|
||||||
|
if (!authorization || !alive.value) return
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.status({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
attemptID: authorization.attemptID,
|
||||||
|
location: location(),
|
||||||
})
|
})
|
||||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
.then((value) => ({ ok: true as const, status: value.data }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||||
dispatch({ type: "auth.error", error: message })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (result.status.status === "complete") {
|
||||||
await complete()
|
await complete()
|
||||||
})()
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "failed") {
|
||||||
|
dispatch({ type: "auth.error", error: result.status.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "expired") {
|
||||||
|
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.current = setTimeout(poll, 1_000)
|
||||||
|
}
|
||||||
|
void poll()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1178,15 +1154,15 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "api"}>
|
<Match when={method()?.type === "key"}>
|
||||||
<ApiAuthView />
|
<ApiAuthView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<Match when={method()?.type === "oauth"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Match when={store.authorization?.mode === "code"}>
|
||||||
<OAuthCodeView />
|
<OAuthCodeView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
<Match when={store.authorization?.mode === "auto"}>
|
||||||
<OAuthAutoView />
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
|
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
@@ -177,7 +178,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
|||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.api.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
activeTreeNavigation,
|
activeTreeNavigation,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
async (): Promise<Path | undefined> => {
|
||||||
sdk.client.path
|
if ((await sdk.protocol) !== "v1") return
|
||||||
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined),
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
@@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
const [suggestions] = createResource(input, async (value) => {
|
||||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
const cleaned = cleanPickerInput(value)
|
||||||
|
const typed = cleaned.replace(/\/+$/, "")
|
||||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||||
if (!typed || typed === current) return { query: value, items: [] }
|
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.client.find
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
.then((result) => result.data ?? [])
|
const files = await sdk.api.file
|
||||||
|
.find({
|
||||||
|
location: { directory: base },
|
||||||
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
|
type: "file",
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...directories,
|
||||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
@@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.client.file
|
return sdk.api.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) =>
|
||||||
|
result.data.map((entry) => ({
|
||||||
|
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||||
|
type: entry.type,
|
||||||
|
})),
|
||||||
|
)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
@@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
{(suggestion, index) => (
|
{(suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
id={`directory-picker-v2-suggestion-${index()}`}
|
id={`directory-picker-v2-suggestion-${index()}`}
|
||||||
|
data-directory-path={suggestion.absolute}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index() === activeSuggestion()}
|
aria-selected={index() === activeSuggestion()}
|
||||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -59,10 +60,11 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async () => {
|
async (): Promise<Path | undefined> => {
|
||||||
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((x) => x.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
@@ -162,7 +164,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const path = displayPickerPath(item.absolute, filter(), home())
|
const path = displayPickerPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
@@ -174,7 +176,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || toggle.isPending) return
|
if (!x || x.status === "pending" || toggle.isPending) return
|
||||||
toggle.mutate(x.name)
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (toggle.isPending) return
|
if (toggle.isPending) return
|
||||||
toggle.mutate(i.name)
|
toggle.mutate(i.name)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||||
<div ref={listEl} class="flex min-h-0 flex-col">
|
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||||
<div class="flex w-full flex-col items-start pb-3">
|
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
@@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
{(provider) => (
|
{(provider) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-provider-id={provider.id}
|
||||||
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||||
classList={{
|
classList={{
|
||||||
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import {
|
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||||
Component,
|
|
||||||
ComponentProps,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
JSX,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
ValidComponent,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
@@ -122,14 +113,13 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</Kobalte.Trigger>
|
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
@@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
|
|||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const model = props.model ?? useLocal().model
|
|
||||||
const language = useLanguage()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const controller = createModelSelectorController({
|
||||||
let searchRef: HTMLInputElement | undefined
|
model: props.model,
|
||||||
let contentRef: HTMLDivElement | undefined
|
provider: () => props.provider,
|
||||||
let restoreTrigger = true
|
onSelect: () => props.onClose?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorPopoverV2View
|
||||||
|
trigger={props.trigger}
|
||||||
|
models={controller.models}
|
||||||
|
groups={controller.groups}
|
||||||
|
current={controller.current}
|
||||||
|
select={controller.select}
|
||||||
|
onManage={() => {
|
||||||
|
void import("./dialog-manage-models").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModelSelectorController(input: {
|
||||||
|
provider: () => string | undefined
|
||||||
|
model?: ModelState
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const model = input.model ?? useLocal().model
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||||
)
|
)
|
||||||
const models = createMemo(() => {
|
|
||||||
const search = store.search.trim()
|
|
||||||
const filtered = search
|
|
||||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
: allModels()
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
models: (search: string) => {
|
||||||
|
const query = search.trim()
|
||||||
|
const filtered = query
|
||||||
|
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||||
|
: allModels()
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
},
|
||||||
const groups = createMemo(() => {
|
groups: (models: ModelItem[]) => {
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
for (const item of models()) {
|
for (const item of models) {
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
}
|
}
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
})
|
},
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
current: () => {
|
||||||
const current = () => {
|
|
||||||
const value = model.current()
|
const value = model.current()
|
||||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
return value ? modelKey(value) : undefined
|
||||||
|
},
|
||||||
|
select: (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
input.onSelect()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorPopoverV2View(props: {
|
||||||
|
trigger: ModelSelectorTrigger
|
||||||
|
models: (search: string) => ModelItem[]
|
||||||
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
|
current: () => string | undefined
|
||||||
|
select: (item: ModelItem) => void
|
||||||
|
onManage: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
|
|
||||||
|
const models = createMemo(() => props.models(store.search))
|
||||||
|
const groups = createMemo(() => props.groups(models()))
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = current()
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreTrigger = true
|
dismiss.allowTriggerRestore()
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
const select = (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
props.onClose?.()
|
|
||||||
}
|
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => select(item))
|
dismiss.afterClose(() => props.select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => {
|
dismiss.afterClose(props.onManage)
|
||||||
void import("./dialog-manage-models").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const search = value.trim()
|
const first = props.models(value)[0]
|
||||||
const first = [...allModels()]
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<MenuV2.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => props.onClose?.())
|
dismiss.afterClose(props.onClose)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
@@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={current()}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
@@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (normalized === input.original.http.url) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
@@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const raw = items()
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? raw
|
||||||
|
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
@@ -544,6 +562,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
startEdit,
|
startEdit,
|
||||||
resetForm,
|
resetForm,
|
||||||
submitForm,
|
submitForm,
|
||||||
|
canRemove: server.canRemove,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||||
@@ -631,6 +650,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={props.controller.canRemove(key)}>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||||
@@ -638,6 +658,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
|||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
client: {
|
api: {
|
||||||
find: {
|
file: {
|
||||||
files: (input: { directory: string }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.directory)
|
directories.push(input.location?.directory ?? "")
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve({ data: [] })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
|||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("searches from an absolute root without a default base", async () => {
|
||||||
|
const directories: string[] = []
|
||||||
|
const sdk = {
|
||||||
|
api: {
|
||||||
|
file: {
|
||||||
|
list: (input: { location?: { directory?: string } }) => {
|
||||||
|
directories.push(input.location?.directory ?? "")
|
||||||
|
return Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{ path: "Users/", type: "directory" },
|
||||||
|
{ path: "tmp/", type: "directory" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||||
|
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||||
|
|
||||||
|
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||||
|
expect(directories).toEqual(["/"])
|
||||||
|
})
|
||||||
|
|
||||||
test("identifies the next directory level to preload", () => {
|
test("identifies the next directory level to preload", () => {
|
||||||
expect(
|
expect(
|
||||||
preloadTreeDirectories("src/", [
|
preloadTreeDirectories("src/", [
|
||||||
|
|||||||
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
|
const raw = normalizePickerDrive(value)
|
||||||
|
const root = pickerRoot(raw)
|
||||||
|
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||||
const base = args.base()
|
const base = args.base()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||||
const home = args.home()
|
const home = args.home()
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||||
const root = pickerRoot(raw)
|
|
||||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
|
||||||
return { directory: trimPickerPath(base), path: raw }
|
return { directory: trimPickerPath(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.client.file
|
const request = args.sdk.api.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.filter((node) => node.type === "directory")
|
||||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
.map((node) => {
|
||||||
|
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||||
|
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
cache.set(key, request)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.client.find
|
const results = await args.sdk.api.file
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
@@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await serverCtx().sdk.client.project.update({
|
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||||
|
const project = await serverCtx()
|
||||||
|
.sdk.client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
})
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
if (!project) return
|
||||||
|
// const project = await serverCtx().sdk.api.project.update({
|
||||||
|
// projectID: props.project.id,
|
||||||
|
// name,
|
||||||
|
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
// commands: { start },
|
||||||
|
// })
|
||||||
|
serverCtx().sync.set("project", (items) =>
|
||||||
|
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||||
|
)
|
||||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
edit?: PromptInputProps["edit"]
|
|
||||||
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
readonly model: PromptInputProps["controls"]["model"]
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
}
|
}
|
||||||
@@ -51,9 +49,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
useCommands(props)
|
|
||||||
useEditHandler(props)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
@@ -82,70 +77,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
|
||||||
const prompt = usePrompt()
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.edit?.id,
|
|
||||||
(id) => {
|
|
||||||
const edit = props.edit
|
|
||||||
if (!id || !edit) return
|
|
||||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
|
||||||
edit.context.forEach((item) =>
|
|
||||||
prompt.context.add({
|
|
||||||
type: item.type,
|
|
||||||
path: item.path,
|
|
||||||
selection: item.selection,
|
|
||||||
comment: item.comment,
|
|
||||||
commentID: item.commentID,
|
|
||||||
commentOrigin: item.commentOrigin,
|
|
||||||
preview: item.preview,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
props.controller.dispatch({ type: "mode.normal" })
|
|
||||||
props.controller.resetHistory()
|
|
||||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
|
||||||
props.controller.restoreFocus()
|
|
||||||
props.onEditLoaded?.()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCommands = (props: PromptInputV2ComposerProps) => {
|
|
||||||
const command = useCommand()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
command.register("prompt-input", () => [
|
|
||||||
{
|
|
||||||
id: "file.attach",
|
|
||||||
title: language.t("prompt.action.attachFile"),
|
|
||||||
category: language.t("command.category.file"),
|
|
||||||
keybind: "mod+u",
|
|
||||||
disabled: props.controller.state.mode !== "normal",
|
|
||||||
onSelect: () => props.controller.attach(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.shell",
|
|
||||||
title: language.t("command.prompt.mode.shell"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+x",
|
|
||||||
disabled: props.controller.state.mode === "shell",
|
|
||||||
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.normal",
|
|
||||||
title: language.t("command.prompt.mode.normal"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+e",
|
|
||||||
disabled: props.controller.state.mode === "normal",
|
|
||||||
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -310,7 +241,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
)
|
)
|
||||||
const resources = createMemo(() =>
|
const resources = createMemo(() =>
|
||||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||||
id: `resource:${resource.client}:${resource.uri}`,
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
kind: "resource" as const,
|
kind: "resource" as const,
|
||||||
label: `@${resource.name}`,
|
label: `@${resource.name}`,
|
||||||
path: resource.uri,
|
path: resource.uri,
|
||||||
@@ -327,7 +258,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
source: {
|
source: {
|
||||||
type: "resource" as const,
|
type: "resource" as const,
|
||||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||||
clientName: resource.client,
|
clientName: resource.server,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -447,15 +378,16 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
placeholder: designPlaceholder,
|
placeholder: designPlaceholder,
|
||||||
agent:
|
get agent() {
|
||||||
props.controls.agents.visible && props.controls.agents.options.length > 0
|
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||||
? {
|
? {
|
||||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||||
current: () => props.controls.agents.current,
|
current: () => props.controls.agents.current,
|
||||||
onSelect: props.controls.agents.select,
|
onSelect: (value: string) => props.controls.agents.select(value),
|
||||||
keybind: () => command.keybindParts("agent.cycle"),
|
keybind: () => command.keybindParts("agent.cycle"),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||||
@@ -471,6 +403,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: controller.state.mode !== "normal",
|
||||||
|
onSelect: () => controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: controller.state.mode === "shell",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+e",
|
||||||
|
disabled: controller.state.mode === "normal",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||||
|
edit.context.forEach((item) =>
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
controller.dispatch({ type: "mode.normal" })
|
||||||
|
controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return controller as PromptInputV2ComposerController
|
return controller as PromptInputV2ComposerController
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +508,7 @@ function PromptInputV2ModelControl(props: {
|
|||||||
fallback={
|
fallback={
|
||||||
<ButtonV2
|
<ButtonV2
|
||||||
data-action="prompt-model"
|
data-action="prompt-model"
|
||||||
|
data-control-type="dialog"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
@@ -533,19 +522,22 @@ function PromptInputV2ModelControl(props: {
|
|||||||
>
|
>
|
||||||
<ModelSelectorPopoverV2
|
<ModelSelectorPopoverV2
|
||||||
model={props.model}
|
model={props.model}
|
||||||
triggerAs={ButtonV2}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<ButtonV2
|
||||||
variant: "ghost-muted",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost-muted"
|
||||||
style: { height: "28px" },
|
size="normal"
|
||||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
style={{ height: "28px" }}
|
||||||
classList: { "animate-in fade-in": shouldAnimate() },
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
"data-action": "prompt-model",
|
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||||
}}
|
data-action="prompt-model"
|
||||||
onClose={props.onClose}
|
data-control-type="popover"
|
||||||
>
|
>
|
||||||
{content()}
|
{content()}
|
||||||
</ModelSelectorPopoverV2>
|
</ButtonV2>
|
||||||
|
)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { createPromptState } from "@/context/prompt"
|
import { createPromptState } from "@/context/prompt"
|
||||||
|
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||||
|
|
||||||
function createPromptInputStoryRuntime() {
|
function createPromptInputStoryRuntime() {
|
||||||
@@ -28,8 +30,16 @@ function PromptInputExample() {
|
|||||||
activeTab: undefined as string | undefined,
|
activeTab: undefined as string | undefined,
|
||||||
reviewOpen: false,
|
reviewOpen: false,
|
||||||
})
|
})
|
||||||
|
const storyModel = {
|
||||||
|
id: "claude-3-7-sonnet",
|
||||||
|
name: "Claude 3.7 Sonnet",
|
||||||
|
provider: { id: "anthropic", name: "Anthropic" },
|
||||||
|
}
|
||||||
const model = {
|
const model = {
|
||||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
current: () => storyModel,
|
||||||
|
list: () => [storyModel],
|
||||||
|
visible: () => true,
|
||||||
|
set: () => {},
|
||||||
variant: {
|
variant: {
|
||||||
list: () => ["fast", "thinking"],
|
list: () => ["fast", "thinking"],
|
||||||
current: () => controls.variant,
|
current: () => controls.variant,
|
||||||
@@ -65,7 +75,6 @@ function PromptInputExample() {
|
|||||||
open: () => setControls("reviewOpen", true),
|
open: () => setControls("reviewOpen", true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
}
|
||||||
const addReviewComment = () => {
|
const addReviewComment = () => {
|
||||||
const comment = controls.comments + 1
|
const comment = controls.comments + 1
|
||||||
@@ -102,6 +111,93 @@ function PromptInputExample() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todos: Todo[] = [
|
||||||
|
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||||
|
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function PromptInputWithOpenDock() {
|
||||||
|
const input = createPromptInputStoryRuntime()
|
||||||
|
const [controls, setControls] = createStore({
|
||||||
|
agent: "build",
|
||||||
|
activeTab: undefined as string | undefined,
|
||||||
|
todoCollapsed: false,
|
||||||
|
})
|
||||||
|
const inputControls = {
|
||||||
|
agents: {
|
||||||
|
available: [],
|
||||||
|
options: ["build"],
|
||||||
|
get current() {
|
||||||
|
return controls.agent
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
visible: true,
|
||||||
|
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
selection: {
|
||||||
|
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||||
|
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||||
|
},
|
||||||
|
paid: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: "story-session",
|
||||||
|
tabs: {
|
||||||
|
active: () => controls.activeTab,
|
||||||
|
all: () => [],
|
||||||
|
open: () => {},
|
||||||
|
setActive: (tab: string) => setControls("activeTab", tab),
|
||||||
|
},
|
||||||
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
blocked: () => false,
|
||||||
|
questionRequest: () => undefined,
|
||||||
|
permissionRequest: () => undefined,
|
||||||
|
permissionResponding: () => false,
|
||||||
|
decide: () => {},
|
||||||
|
todos: () => todos,
|
||||||
|
dock: () => true,
|
||||||
|
closing: () => false,
|
||||||
|
opening: () => false,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SessionComposerRegion
|
||||||
|
controller={createSessionComposerRegionController({
|
||||||
|
state,
|
||||||
|
sessionKey: () => "story-session",
|
||||||
|
sessionID: () => "story-session",
|
||||||
|
prompt: input.state,
|
||||||
|
ready: () => true,
|
||||||
|
centered: () => false,
|
||||||
|
todo: {
|
||||||
|
collapsed: () => controls.todoCollapsed,
|
||||||
|
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||||
|
},
|
||||||
|
followup: () => undefined,
|
||||||
|
revert: () => undefined,
|
||||||
|
onResponseSubmit: () => {},
|
||||||
|
openParent: () => {},
|
||||||
|
setPromptRef: () => {},
|
||||||
|
setDockRef: () => {},
|
||||||
|
})}
|
||||||
|
promptInput={
|
||||||
|
<PromptInput
|
||||||
|
controls={inputControls}
|
||||||
|
{...input}
|
||||||
|
ref={() => {}}
|
||||||
|
newSessionWorktree=""
|
||||||
|
onNewSessionWorktreeReset={() => {}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "App/PromptInput",
|
title: "App/PromptInput",
|
||||||
id: "app-prompt-input",
|
id: "app-prompt-input",
|
||||||
@@ -116,3 +212,12 @@ export const Basic = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DockAlreadyOpen = {
|
||||||
|
render: () => (
|
||||||
|
<div class="pt-10">
|
||||||
|
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||||
|
<PromptInputWithOpenDock />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
client: resource.client,
|
client: resource.server,
|
||||||
display: resource.name,
|
display: resource.name,
|
||||||
description: resource.description,
|
description: resource.description,
|
||||||
mime: resource.mimeType,
|
mime: resource.mimeType,
|
||||||
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
type: "custom" as const,
|
type: "custom" as const,
|
||||||
source: cmd.source,
|
// source: cmd.source,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...custom, ...builtin]
|
return [...custom, ...builtin]
|
||||||
@@ -1723,15 +1723,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.controls.model.selection}
|
model={props.controls.model.selection}
|
||||||
triggerAs={Button}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<Button
|
||||||
variant: "ghost",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost"
|
||||||
style: control(),
|
size="normal"
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
style={control()}
|
||||||
"data-action": "prompt-model",
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
}}
|
data-action="prompt-model"
|
||||||
onClose={restoreFocus}
|
|
||||||
>
|
>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1745,7 +1744,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
language.t("dialog.model.select.title")}
|
language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</ModelSelectorPopover>
|
</Button>
|
||||||
|
)}
|
||||||
|
onClose={restoreFocus}
|
||||||
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
|||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
|
const sessionCreateInputs: Array<{
|
||||||
|
agent?: string
|
||||||
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
|
location?: { directory: string }
|
||||||
|
}> = []
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -20,9 +25,14 @@ const optimistic: Array<{
|
|||||||
const optimisticSeeded: boolean[] = []
|
const optimisticSeeded: boolean[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
|
const sentPrompts: string[] = []
|
||||||
|
const promptInputs: unknown[] = []
|
||||||
|
const sentCommands: unknown[] = []
|
||||||
|
const commands: Array<{ name: string }> = []
|
||||||
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
@@ -31,7 +41,7 @@ let variant: string | undefined
|
|||||||
let permissionServer = "server-a"
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
prompt: promptValue,
|
prompt: promptValue,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -63,23 +73,39 @@ const prompt = {
|
|||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
return {
|
return {
|
||||||
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async () => {
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
createdSessions.push(directory)
|
const location = input.location?.directory ?? directory
|
||||||
|
createdSessions.push(location)
|
||||||
|
sessionCreateInputs.push(input)
|
||||||
return {
|
return {
|
||||||
data: {
|
|
||||||
id: `session-${createdSessions.length}`,
|
id: `session-${createdSessions.length}`,
|
||||||
|
projectID: "project",
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
title: `New session ${createdSessions.length}`,
|
title: `New session ${createdSessions.length}`,
|
||||||
},
|
location: { directory: location },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
shell: async () => {
|
prompt: async (input: unknown) => {
|
||||||
sentShell.push(directory)
|
sentPrompts.push(directory)
|
||||||
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
command: async (input: unknown) => {
|
||||||
promptAsync: async () => ({ data: undefined }),
|
sentCommands.push(input)
|
||||||
|
},
|
||||||
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
|
sentShell.push(input)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
@@ -172,6 +198,7 @@ beforeAll(async () => {
|
|||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
@@ -183,7 +210,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => ({
|
||||||
data: { command: [] },
|
data: { command: commands },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: (value: {
|
||||||
@@ -210,6 +237,9 @@ beforeAll(async () => {
|
|||||||
session: {
|
session: {
|
||||||
remember: () => undefined,
|
remember: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
|
sync: async () => {
|
||||||
|
serverSessionSyncs++
|
||||||
|
},
|
||||||
},
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
@@ -251,11 +281,17 @@ beforeAll(async () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
sessionCreateInputs.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
sentPrompts.length = 0
|
||||||
|
promptInputs.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
|
commands.length = 0
|
||||||
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
@@ -264,6 +300,7 @@ beforeEach(() => {
|
|||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
permissionServer = "server-a"
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
|
serverSessionSyncs = 0
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(sessionCreateInputs).toEqual([
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-a" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-b" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(sentShell).toEqual([
|
||||||
|
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
])
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
expect(promoted).toEqual([
|
expect(promoted).toEqual([
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
@@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
expect(optimistic).toHaveLength(1)
|
||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
@@ -427,6 +481,56 @@ describe("prompt submit worktree selection", () => {
|
|||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
expect(sentPrompts).toEqual(["/repo/main"])
|
||||||
|
expect(promptInputs[0]).toMatchObject({
|
||||||
|
sessionID: "session-1",
|
||||||
|
text: "ls",
|
||||||
|
files: [],
|
||||||
|
agents: [],
|
||||||
|
})
|
||||||
|
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||||
|
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||||
|
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits slash commands through the current session API", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
variant = "high"
|
||||||
|
commands.push({ name: "review" })
|
||||||
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(sentCommands).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: expect.stringMatching(/^msg_/),
|
||||||
|
command: "review",
|
||||||
|
arguments: "staged changes",
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: "high" },
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("uses an injected model selection", async () => {
|
||||||
@@ -487,7 +591,8 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||||
|
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||||
expect(optimisticSeeded).toEqual([true])
|
expect(optimisticSeeded).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
|
|||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -39,7 +41,7 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: DirectorySDK["client"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.command({
|
const messageID = Identifier.ascending("message")
|
||||||
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
arguments: tail.join(" "),
|
arguments: tail.join(" "),
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: {
|
||||||
|
id: input.draft.model.modelID,
|
||||||
|
providerID: input.draft.model.providerID,
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
parts: images.map((attachment) => ({
|
},
|
||||||
id: Identifier.ascending("part"),
|
files: images.map((attachment) => ({
|
||||||
type: "file" as const,
|
uri: attachment.dataUrl,
|
||||||
mime: attachment.mime,
|
name: attachment.filename,
|
||||||
url: attachment.dataUrl,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -152,13 +156,37 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.promptAsync({
|
await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: input.draft.model,
|
||||||
messageID,
|
|
||||||
parts: requestParts,
|
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
|
legacyParts: requestParts,
|
||||||
|
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
|
files: requestParts.flatMap((part) => {
|
||||||
|
if (part.type !== "file") return []
|
||||||
|
const text = part.source?.text
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
uri: part.url,
|
||||||
|
name: part.filename,
|
||||||
|
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
agents: requestParts.flatMap((part) =>
|
||||||
|
part.type === "agent"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: part.name,
|
||||||
|
mention: part.source
|
||||||
|
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -210,6 +238,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
const data = (err as { data?: { message?: string } }).data
|
const data = (err as { data?: { message?: string } }).data
|
||||||
if (data?.message) return data.message
|
if (data?.message) return data.message
|
||||||
@@ -222,6 +251,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
|
serverSync().session.set("todo", sessionID, [])
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const key = pendingKey(sessionID)
|
const key = pendingKey(sessionID)
|
||||||
@@ -233,9 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.client.session.abort({
|
.api.session.interrupt({ sessionID })
|
||||||
sessionID,
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,9 +391,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await client.session
|
const created = await sdk()
|
||||||
.create()
|
.api.session.create({
|
||||||
.then((x) => x.data ?? undefined)
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
|
})
|
||||||
|
.then(normalizeSessionInfo)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
@@ -448,12 +481,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const eventID = Event.ID.create()
|
||||||
.shell({
|
sdk()
|
||||||
|
.api.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: eventID,
|
||||||
|
command: text,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
command: text,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -471,23 +506,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const messageID = Identifier.ascending("message")
|
||||||
.command({
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
|
sdk()
|
||||||
|
.api.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent,
|
agent,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
variant,
|
files: images.map((attachment) => ({
|
||||||
parts: images.map((attachment) => ({
|
uri: attachment.dataUrl,
|
||||||
id: Identifier.ascending("part"),
|
name: attachment.filename,
|
||||||
type: "file" as const,
|
|
||||||
mime: attachment.mime,
|
|
||||||
url: attachment.dataUrl,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
@@ -571,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft,
|
draft,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
|
|
||||||
export type PromptProject = {
|
export type PromptProject = {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
let triggerFrame: number | undefined
|
let triggerFrame: number | undefined
|
||||||
let restoreTrigger = true
|
|
||||||
|
|
||||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
|
|||||||
props.controller.active()
|
props.controller.active()
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
: undefined
|
: undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const selectProject = (project: PromptProject) => {
|
const selectProject = (project: PromptProject) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.select(project))
|
dismiss.afterClose(() => props.controller.select(project))
|
||||||
}
|
}
|
||||||
const selectAction = (server?: string) => {
|
const selectAction = (server?: string) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.add(server))
|
dismiss.afterClose(() => props.controller.add(server))
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const project = props.controller.activeProject()
|
const project = props.controller.activeProject()
|
||||||
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
|
|||||||
)
|
)
|
||||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||||
.findLast((element) => element.offsetParent !== null)
|
.findLast((element) => element.offsetParent !== null)
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
target?.focus()
|
target?.focus()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (props.controller.open()) props.controller.setOpen(false)
|
if (props.controller.open()) props.controller.setOpen(false)
|
||||||
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
|
|||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => {
|
||||||
|
if (open) dismiss.allowTriggerRestore()
|
||||||
|
props.controller.setOpen(open)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
|
|||||||
id="prompt-project-menu"
|
id="prompt-project-menu"
|
||||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -364,6 +356,7 @@ export function PromptProjectSelector(props: {
|
|||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="max-h-[224px] overflow-y-auto">
|
||||||
<Show
|
<Show
|
||||||
when={props.controller.servers().length > 1}
|
when={props.controller.servers().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -389,7 +382,9 @@ export function PromptProjectSelector(props: {
|
|||||||
{server!.name}
|
{server!.name}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
<For
|
||||||
|
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
|
||||||
|
>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
)}
|
)}
|
||||||
@@ -400,6 +395,7 @@ export function PromptProjectSelector(props: {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="h-px bg-v2-border-border-muted" />
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -15,9 +15,49 @@ export const ServerRowMenu: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const key = ServerConnection.key(props.server)
|
const key = ServerConnection.key(props.server)
|
||||||
const builtin = ServerConnection.builtin(props.server)
|
return (
|
||||||
const isDefault = () => props.controller.defaultKey() === key
|
<ServerRowMenuView
|
||||||
|
server={props.server}
|
||||||
|
labels={serverMenuLabels(language)}
|
||||||
|
canDefault={props.controller.canDefault()}
|
||||||
|
isDefault={props.controller.defaultKey() === key}
|
||||||
|
canRemove={props.controller.canRemove(key)}
|
||||||
|
onEdit={props.onEdit}
|
||||||
|
onSetDefault={() => props.controller.setDefault(key)}
|
||||||
|
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||||
|
onRemove={() => props.controller.handleRemove(key)}
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||||
|
return {
|
||||||
|
more: language.t("common.moreOptions"),
|
||||||
|
server: language.t("settings.section.server"),
|
||||||
|
edit: language.t("dialog.server.menu.edit"),
|
||||||
|
default: language.t("dialog.server.menu.default"),
|
||||||
|
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||||
|
delete: language.t("dialog.server.menu.delete"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerRowMenuView: Component<{
|
||||||
|
server: ServerConnection.Any
|
||||||
|
labels: ReturnType<typeof serverMenuLabels>
|
||||||
|
canDefault: boolean
|
||||||
|
isDefault: boolean
|
||||||
|
canRemove: boolean
|
||||||
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
|
onSetDefault: () => void
|
||||||
|
onRemoveDefault: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}> = (props) => {
|
||||||
|
const builtin = () => ServerConnection.builtin(props.server)
|
||||||
|
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||||
return (
|
return (
|
||||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
@@ -25,32 +65,31 @@ export const ServerRowMenu: Component<{
|
|||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconV2 name="outline-dots" />}
|
icon={<IconV2 name="outline-dots" />}
|
||||||
aria-label={language.t("common.moreOptions")}
|
aria-label={props.labels.more}
|
||||||
/>
|
/>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content>
|
<MenuV2.Content>
|
||||||
<MenuV2.Group>
|
<MenuV2.Group>
|
||||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||||
<MenuV2.Item
|
<MenuV2.Item
|
||||||
disabled={builtin || props.server.type !== "http"}
|
disabled={builtin() || !httpServer()}
|
||||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
onSelect={() => {
|
||||||
|
const server = httpServer()
|
||||||
|
if (server) props.onEdit(server)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.menu.edit")}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
|
||||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
|
<Show when={props.canDefault && !props.isDefault}>
|
||||||
|
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && isDefault()}>
|
<Show when={props.canDefault && props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={props.canRemove}>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.delete")}
|
</Show>
|
||||||
</MenuV2.Item>
|
|
||||||
</MenuV2.Group>
|
</MenuV2.Group>
|
||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
|
|||||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
export { NewSessionView } from "./session-new-view"
|
export { NewSessionView } from "./session-new-view"
|
||||||
export { NewSessionDesignView } from "./session-new-design-view"
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { JSX } from "solid-js"
|
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|
||||||
|
|
||||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
|
||||||
return (
|
|
||||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
|
||||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
|
||||||
<div class="mt-8">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
async () => {
|
||||||
serverSdk()
|
const sdk = serverSdk()
|
||||||
.client.pty.shells()
|
if ((await sdk.protocol) === "v1") {
|
||||||
.then((res) => res.data ?? [])
|
return (await sdk.client.pty.shells()).data ?? []
|
||||||
.catch(() => [] as ShellOption[]),
|
}
|
||||||
|
// return (await sdk.api.pty.shells()).data
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user