mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 16:06:23 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f1361a149 | ||
|
|
bd54dc508f | ||
|
|
0b3c45c948 | ||
|
|
689a354a43 | ||
|
|
acb462a173 | ||
|
|
e26cbb935e | ||
|
|
2516d8e976 | ||
|
|
fa6dd9df8f | ||
|
|
b52b79d28f | ||
|
|
632b65854c | ||
|
|
ed877cfebc | ||
|
|
3a08d95907 | ||
|
|
b09a74591c | ||
|
|
e8481973ce | ||
|
|
211cd73f1a | ||
|
|
2375e81bd6 | ||
|
|
8352addf6c | ||
|
|
32f89748af | ||
|
|
89478b36f1 | ||
|
|
46458f0753 | ||
|
|
c907d2ba27 | ||
|
|
ffac1c5b11 | ||
|
|
c5dca2df37 | ||
|
|
8889447f5a | ||
|
|
a1cb005799 | ||
|
|
19833ad1fd | ||
|
|
cb852434b1 | ||
|
|
b3733e9517 | ||
|
|
331f4ecd2f | ||
|
|
ecaa914b79 | ||
|
|
4f8dea674a | ||
|
|
4bf5269c4c | ||
|
|
c9d240704d | ||
|
|
c9df4ba80d | ||
|
|
ac71a55294 | ||
|
|
f84d927e07 | ||
|
|
d431fedce5 | ||
|
|
e536b9627e | ||
|
|
6af46cc8a9 | ||
|
|
c76e602ba4 | ||
|
|
726107729e | ||
|
|
0808ebc3c5 | ||
|
|
17e5c5fbf1 | ||
|
|
b0a13b810e | ||
|
|
6575215ddf | ||
|
|
09157609b7 | ||
|
|
7ba5f3e5b2 | ||
|
|
0ec1587948 | ||
|
|
2fda2c8141 | ||
|
|
f06bf47e72 | ||
|
|
fa6e7e179d | ||
|
|
4272bb83e0 | ||
|
|
1ae2cf3567 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: 1.4.1
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -1065,6 +1065,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -1102,7 +1103,7 @@
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
@@ -3032,7 +3033,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
|
||||
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
||||
|
||||
@@ -3484,7 +3485,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -6340,10 +6341,6 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
@@ -6922,8 +6919,6 @@
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
|
||||
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
|
||||
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
|
||||
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
|
||||
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
|
||||
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
|
||||
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
|
||||
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
@@ -43,7 +43,7 @@
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
@@ -159,6 +159,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
+38
-1
@@ -247,6 +247,8 @@ it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
@@ -259,7 +261,7 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
|
||||
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
|
||||
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
@@ -279,6 +281,41 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Streamed checkpoint compaction
|
||||
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
|
||||
|
||||
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
|
||||
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
|
||||
### Advanced: in-band compaction
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages.js"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
@@ -50,18 +50,24 @@ interface Budget {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
const markLastTool = (
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<ToolDefinition> => {
|
||||
if (tools.length === 0) return tools
|
||||
const last = tools.length - 1
|
||||
if (tools[last]!.cache || budget.remaining === 0) return tools
|
||||
const markLastTool = (tools: ReadonlyArray<ToolEntry>, hint: CacheHint, budget: Budget): ReadonlyArray<ToolEntry> => {
|
||||
const target = tools.at(-1)
|
||||
if (target === undefined) return tools
|
||||
if (target.type === "namespace") {
|
||||
const nested = markLastTool(target.tools, hint, budget)
|
||||
return nested === target.tools ? tools : [...tools.slice(0, -1), { ...target, tools: nested }]
|
||||
}
|
||||
if (target.cache || budget.remaining === 0) return tools
|
||||
budget.remaining -= 1
|
||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||
return [...tools.slice(0, -1), new ToolDefinition({ ...target, cache: hint })]
|
||||
}
|
||||
|
||||
const countToolHints = (tools: ReadonlyArray<ToolEntry>): number =>
|
||||
tools.reduce(
|
||||
(count, tool) => count + (tool.type === "tool" ? (tool.cache === undefined ? 0 : 1) : countToolHints(tool.tools)),
|
||||
0,
|
||||
)
|
||||
|
||||
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
|
||||
if (system.length === 0) return system
|
||||
let changed = false
|
||||
@@ -122,7 +128,7 @@ const markMessages = (
|
||||
}
|
||||
|
||||
const countHints = (request: LLMRequest) =>
|
||||
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
|
||||
countToolHints(request.tools) +
|
||||
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
|
||||
request.messages.reduce(
|
||||
(count, message) =>
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
LanguageModel,
|
||||
SystemPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
ToolEntry,
|
||||
type ContentPart,
|
||||
type LanguageModelProviderOptions,
|
||||
type ToolEntryInput,
|
||||
} from "./schema/index.js"
|
||||
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool.js"
|
||||
|
||||
@@ -27,7 +28,7 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
|
||||
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
|
||||
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
|
||||
readonly messages?: ReadonlyArray<Message | Message.Input>
|
||||
readonly tools?: ReadonlyArray<ToolDefinition.Input>
|
||||
readonly tools?: ReadonlyArray<ToolEntryInput>
|
||||
readonly toolChoice?: ToolChoice.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
|
||||
@@ -56,7 +57,7 @@ export const request = <const SelectedLanguageModel extends LanguageModel>(
|
||||
...rest,
|
||||
system: SystemPart.content(requestSystem),
|
||||
messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])],
|
||||
tools: tools?.map(ToolDefinition.make) ?? [],
|
||||
tools: tools?.map(ToolEntry.make) ?? [],
|
||||
toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
|
||||
generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),
|
||||
providerOptions: requestProviderOptions,
|
||||
|
||||
@@ -1067,10 +1067,11 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const tools =
|
||||
request.tools.length === 0
|
||||
flattened.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
: flattened.tools.map((tool) =>
|
||||
lowerTool(
|
||||
breakpoints,
|
||||
tool,
|
||||
@@ -1088,7 +1089,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
}))
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
const messages = yield* lowerMessages(flattened.request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||
|
||||
@@ -427,21 +427,22 @@ const lowerSystem = (
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig = (() => {
|
||||
if (request.tools.length === 0) return undefined
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, flattened.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
}
|
||||
})()
|
||||
const system = lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
const messages = yield* lowerMessages(flattened.request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
|
||||
@@ -508,7 +509,6 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
|
||||
// one finish after both chunks have had a chance to arrive.
|
||||
@@ -620,16 +620,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.toolUse) {
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
if (state.finishedTools.has(index)) return [state, []] as const
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
// A delta for a block that is not open, whether it already stopped or never
|
||||
// started, has nothing to attach to and is dropped.
|
||||
const result = ToolStream.append(
|
||||
state.tools,
|
||||
index,
|
||||
event.contentBlockDelta.contentBlockIndex,
|
||||
event.contentBlockDelta.delta.toolUse.input,
|
||||
"Bedrock Converse tool delta is missing its tool call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
if (!result) return [state, []] as const
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
@@ -668,7 +666,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
@@ -765,7 +762,6 @@ export const protocol = Protocol.make({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
finishReason: undefined,
|
||||
usage: undefined,
|
||||
hasToolCalls: false,
|
||||
|
||||
@@ -465,7 +465,8 @@ function mapSafetySettings(value: unknown) {
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const hasTools = request.tools.length > 0
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = resolveOptions(request)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
@@ -483,7 +484,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
|
||||
return {
|
||||
cachedContent: options.cachedContent,
|
||||
contents: yield* lowerMessages(request),
|
||||
contents: yield* lowerMessages(flattened.request),
|
||||
safetySettings: options.safetySettings,
|
||||
serviceTier: options.serviceTier,
|
||||
systemInstruction:
|
||||
@@ -491,7 +492,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
tools: hasTools
|
||||
? [
|
||||
{
|
||||
functionDeclarations: request.tools.map((tool) =>
|
||||
functionDeclarations: flattened.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -414,10 +414,11 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
|
||||
tool: (name) => ({ type: "function" as const, function: { name } }),
|
||||
})
|
||||
: undefined
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
|
||||
messages: yield* lowerMessages(flattened.request),
|
||||
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: request.generation?.maxTokens,
|
||||
|
||||
@@ -42,6 +42,7 @@ const canonical = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
||||
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
|
||||
return `{${Object.keys(value)
|
||||
.filter((key) => value[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
|
||||
.join(",")}}`
|
||||
@@ -57,7 +58,12 @@ const comparable = (value: unknown) => {
|
||||
if (value.type === "message" && value.role === "assistant")
|
||||
return {
|
||||
role: "assistant",
|
||||
content: value.content,
|
||||
// Annotations and logprobs describe the response, not the text replayed in model input.
|
||||
content: Array.isArray(value.content)
|
||||
? value.content.map((part) =>
|
||||
ProviderShared.isRecord(part) && part.type === "output_text" ? { type: part.type, text: part.text } : part,
|
||||
)
|
||||
: value.content,
|
||||
...(value.phase === undefined ? {} : { phase: value.phase }),
|
||||
}
|
||||
if (value.type === "function_call")
|
||||
@@ -121,7 +127,7 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
let output: unknown[] = []
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
@@ -149,6 +155,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
if (
|
||||
Array.isArray(request.input) &&
|
||||
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
|
||||
)
|
||||
return observation
|
||||
const responseID = event.response?.id
|
||||
if (!responseID || responseID.trim().length === 0) return observation
|
||||
return {
|
||||
@@ -159,7 +171,14 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
version: VERSION,
|
||||
responseID,
|
||||
request,
|
||||
output: event.response?.output ? [...event.response.output] : output.slice(),
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
: item,
|
||||
)
|
||||
: output.slice(),
|
||||
} satisfies CheckpointValue,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ export const InputItem = Schema.Union([
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
arguments: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
@@ -315,6 +316,7 @@ export const StreamItem = Schema.StructWithRest(
|
||||
id: Schema.optional(Schema.String),
|
||||
call_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
namespace: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
}),
|
||||
@@ -433,7 +435,6 @@ export interface ParserState {
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly open: boolean
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
@@ -489,6 +490,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
namespace: part.namespace,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
@@ -808,14 +810,15 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const projected = ProviderShared.flattenToolRequest(request)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
...(yield* lowerConversation(request, adapter)),
|
||||
...(yield* lowerConversation(projected.request, adapter)),
|
||||
...lowerGeneration(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
projected.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
lowerTool(
|
||||
adapter.name,
|
||||
tool,
|
||||
@@ -920,7 +923,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
const outputItemID = (state: ParserState, event: Event) =>
|
||||
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
@@ -932,7 +935,11 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
|
||||
// An item without an id adopts the id already open in its output slot,
|
||||
// otherwise it gets a locally minted one.
|
||||
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
|
||||
const resolveItem = (
|
||||
state: Pick<ParserState, "outputItems">,
|
||||
item: StreamItem,
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
...item,
|
||||
id:
|
||||
item.id ??
|
||||
@@ -942,7 +949,7 @@ const resolveItem = (state: ParserState, item: StreamItem, index: number | undef
|
||||
|
||||
// Registered output slots are authoritative for `item_id` routing, and items
|
||||
// are resolved here so everything downstream can rely on `item.id`.
|
||||
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
|
||||
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
|
||||
...input,
|
||||
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
|
||||
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
|
||||
@@ -950,7 +957,7 @@ export const normalize = (state: ParserState, input: Event): NormalizedEvent =>
|
||||
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
if (!item || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Object.entries(item.summaryParts)
|
||||
@@ -990,7 +997,7 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item?.open) return [state, NO_EVENTS]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
|
||||
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
|
||||
@@ -1015,7 +1022,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
@@ -1074,7 +1081,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: true,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
@@ -1096,11 +1102,20 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name ?? "",
|
||||
namespace: item.namespace,
|
||||
input: item.arguments ?? "",
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
},
|
||||
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: item.call_id,
|
||||
name: item.name ?? "",
|
||||
namespace: item.namespace,
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1112,7 +1127,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item?.open) return [state, NO_EVENTS]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
|
||||
return [
|
||||
{
|
||||
@@ -1219,7 +1234,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const registered = state.tools[item.id] !== undefined
|
||||
const tools = registered
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
|
||||
: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
namespace: item.namespace,
|
||||
providerMetadata: metadata,
|
||||
})
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||
@@ -1230,7 +1250,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const resultEvents =
|
||||
registered || finished.length === 0
|
||||
? finished
|
||||
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
|
||||
: [
|
||||
LLMEvent.toolInputStart({
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
namespace: item.namespace,
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
...finished,
|
||||
]
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
@@ -1247,7 +1275,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (item.type === "reasoning") {
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
@@ -1274,53 +1301,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const reasoningItems = { ...state.reasoningItems }
|
||||
delete reasoningItems[item.id]
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "concluded" },
|
||||
deltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata, text: itemText }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
@@ -736,6 +736,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const provider = String(request.model.provider)
|
||||
const baseURL = request.model.route.endpoint.baseURL
|
||||
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
|
||||
@@ -748,16 +749,16 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
const zaiToolStream =
|
||||
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
const hasActiveTools = request.tools.length > 0
|
||||
const hasActiveTools = flattened.tools.length > 0
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
messages: yield* lowerMessages(flattened.request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
flattened.tools.length === 0
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
: request.tools.map((tool) =>
|
||||
: flattened.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -13,6 +13,7 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -75,7 +76,18 @@ const OpenAIResponsesHostedToolItem = Schema.Union([
|
||||
),
|
||||
])
|
||||
|
||||
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
|
||||
const OpenAIResponsesNamespace = Schema.Struct({
|
||||
type: Schema.tag("namespace"),
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
tools: Schema.Array(OpenResponses.Tool),
|
||||
})
|
||||
|
||||
const OpenAIResponsesTools = Schema.Union([
|
||||
OpenResponses.Tool,
|
||||
OpenAIResponsesNamespace,
|
||||
OpenAIResponsesImageGenerationTool,
|
||||
])
|
||||
|
||||
const OpenAIResponsesToolChoice = Schema.Union([
|
||||
OpenResponses.ToolChoice,
|
||||
@@ -103,6 +115,18 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
/** Request control, never conversation content. */
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -128,13 +152,33 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
|
||||
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
|
||||
// Native namespaces hold only function tools, so deeper levels flatten into
|
||||
// the leaf names the same way non-native protocols flatten the whole tree.
|
||||
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (
|
||||
tool: ToolEntry,
|
||||
compatibility: Parameters<typeof ToolSchemaProjection.modelCompatibility>[1],
|
||||
) {
|
||||
if (tool.type === "tool")
|
||||
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))
|
||||
// OpenAI requires a namespace description; fall back to a generic one so a
|
||||
// missing description never blocks the request.
|
||||
return {
|
||||
type: "namespace" as const,
|
||||
name: tool.name,
|
||||
description: tool.description ?? `Tools in the ${tool.name} namespace.`,
|
||||
tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) =>
|
||||
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, compatibility)),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolEntry>) =>
|
||||
ProviderShared.matchToolChoice(NAME, toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "required" as const,
|
||||
tool: (name) =>
|
||||
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
|
||||
tools.some((tool) => tool.type === "tool" && tool.name === name && nativeImageTool(tool) !== undefined)
|
||||
? ({ type: "image_generation" } as const)
|
||||
: { type: "function" as const, name },
|
||||
})
|
||||
@@ -153,15 +197,42 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
const checkpointBody = {
|
||||
schema: CheckpointBody,
|
||||
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
|
||||
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
|
||||
const overlay = request.http?.body
|
||||
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
|
||||
if (
|
||||
overlay?.input !== undefined ||
|
||||
overlay?.previous_response_id !== undefined ||
|
||||
overlay?.conversation !== undefined
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Trigger compaction requires complete canonical history, not an input or continuation override",
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
|
||||
...mergeJsonRecords(native, overlay),
|
||||
input: [...native.input, { type: "compaction_trigger" }],
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: undefined,
|
||||
context_management: undefined,
|
||||
text: undefined,
|
||||
max_output_tokens: undefined,
|
||||
max_tool_calls: undefined,
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
@@ -239,7 +310,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
UnsupportedOperationError,
|
||||
AIError,
|
||||
HttpContext,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolDefinition,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderID,
|
||||
type TextPart,
|
||||
type ToolEntry,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
@@ -46,6 +49,7 @@ export const promptCacheKey = (request: LLMRequest): string | undefined => {
|
||||
export interface ToolAccumulator {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
readonly input: string
|
||||
}
|
||||
|
||||
@@ -279,6 +283,38 @@ export const unsupportedOperation = (input: {
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Lower namespaces to flat definitions for protocols without a native
|
||||
* namespace construct. Leaf names join their namespace path with `_` because
|
||||
* `.` is not broadly accepted in provider tool names.
|
||||
*/
|
||||
export const flattenTools = (tools: ReadonlyArray<ToolEntry>, path: ReadonlyArray<string> = []) => {
|
||||
const flat = tools.flatMap((tool): ReadonlyArray<ToolDefinition> => {
|
||||
if (tool.type === "namespace") return flattenTools(tool.tools, [...path, tool.name])
|
||||
if (path.length === 0) return [tool]
|
||||
return [new ToolDefinition({ ...tool, name: [...path, tool.name].join("_") })]
|
||||
})
|
||||
return [...new Map(flat.map((tool) => [tool.name, tool])).values()]
|
||||
}
|
||||
|
||||
export const flattenToolRequest = (request: LLMRequest) => {
|
||||
const messages = request.messages.map((message) => {
|
||||
const content = message.content.map((part) => {
|
||||
if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part
|
||||
return { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined }
|
||||
})
|
||||
return content.every((part, index) => part === message.content[index])
|
||||
? message
|
||||
: new Message({ ...message, content })
|
||||
})
|
||||
return {
|
||||
tools: flattenTools(request.tools),
|
||||
request: messages.every((message, index) => message === request.messages[index])
|
||||
? request
|
||||
: LLMRequest.update(request, { messages }),
|
||||
}
|
||||
}
|
||||
|
||||
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
|
||||
route: string,
|
||||
name: string,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
|
||||
import { Protocol } from "../../route/protocol.js"
|
||||
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
interface State {
|
||||
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
|
||||
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
|
||||
readonly responseID?: string
|
||||
}
|
||||
|
||||
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
|
||||
state: State,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
const event = OpenResponses.normalize(state.parser, input)
|
||||
const item = event.item
|
||||
if (!item) return state
|
||||
const parser =
|
||||
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
|
||||
? state.parser
|
||||
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
|
||||
const next = parser === state.parser ? state : { ...state, parser }
|
||||
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
|
||||
if (
|
||||
event.output_index !== undefined &&
|
||||
Object.entries(state.parser.outputItems).some(
|
||||
([index, id]) => id === item.id && Number(index) !== event.output_index,
|
||||
)
|
||||
)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
|
||||
if (!item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
|
||||
const previous = state.checkpoints[item.id]
|
||||
if (previous && previous.encrypted !== item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
|
||||
if (previous) return next
|
||||
return {
|
||||
...next,
|
||||
checkpoints: {
|
||||
...state.checkpoints,
|
||||
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
|
||||
},
|
||||
} satisfies State
|
||||
})
|
||||
|
||||
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
|
||||
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
|
||||
const source = request.model.route
|
||||
let result: CompactionCheckpointResponse | undefined
|
||||
// Route registries erase the frame type. The codec validates that boundary before parsing.
|
||||
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
|
||||
const protocol = Protocol.make({
|
||||
id: source.protocol,
|
||||
body,
|
||||
stream: {
|
||||
event,
|
||||
initial: (request: LLMRequest): State => ({
|
||||
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
|
||||
checkpoints: {},
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
|
||||
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
|
||||
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
|
||||
if (event.type === "error" || event.type === "response.failed")
|
||||
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
|
||||
if (event.type === "response.incomplete")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
|
||||
return [yield* onOutputItem(state, event), []] as const
|
||||
if (event.type !== "response.completed") return [state, []] as const
|
||||
const responseID = event.response?.id
|
||||
if (!responseID?.trim())
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
|
||||
if (event.response?.status !== undefined && event.response.status !== "completed")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
|
||||
let next = state
|
||||
for (const [index, item] of (event.response?.output ?? []).entries()) {
|
||||
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
|
||||
}
|
||||
const checkpoints = Object.values(next.checkpoints)
|
||||
const checkpoint = checkpoints[0]
|
||||
if (checkpoints.length !== 1 || !checkpoint)
|
||||
return yield* ProviderShared.eventError(
|
||||
source.id,
|
||||
"Compaction response must contain exactly one checkpoint",
|
||||
)
|
||||
result = new CompactionCheckpointResponse({
|
||||
checkpoint,
|
||||
responseID,
|
||||
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
|
||||
}),
|
||||
},
|
||||
})
|
||||
const route = Route.make({
|
||||
id: source.id,
|
||||
provider: source.provider,
|
||||
providerMetadataKey: source.providerMetadataKey,
|
||||
protocol,
|
||||
endpoint: source.endpoint,
|
||||
auth: source.auth,
|
||||
transport: source.transport,
|
||||
})
|
||||
const native = yield* body.from(request)
|
||||
// The body builder already applied and validated overlays. Do not let transport reapply them.
|
||||
const preparedRequest = LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
})
|
||||
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
|
||||
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
|
||||
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
|
||||
return result
|
||||
})
|
||||
|
||||
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
|
||||
@@ -55,6 +55,7 @@ const inputStart = (tool: PendingTool) =>
|
||||
LLMEvent.toolInputStart({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
@@ -63,6 +64,7 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
text,
|
||||
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
|
||||
})
|
||||
@@ -85,6 +87,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
@@ -94,7 +97,12 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
}
|
||||
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
LLMEvent.toolInputEnd({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
event,
|
||||
]
|
||||
|
||||
@@ -150,6 +158,7 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
const tool = {
|
||||
id,
|
||||
name,
|
||||
namespace: current?.namespace,
|
||||
input: `${current?.input ?? ""}${delta.text}`,
|
||||
providerExecuted: current?.providerExecuted,
|
||||
providerMetadata: current?.providerMetadata,
|
||||
@@ -159,6 +168,17 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
return appendTool(tools, key, tool, delta.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append argument text to a started tool. Returns `undefined` when no tool is
|
||||
* open under `key`, for protocols that ignore deltas without a matching block.
|
||||
*/
|
||||
export const append = <K extends StreamKey>(tools: State<K>, key: K, text: string): AppendOutcome<K> | undefined => {
|
||||
const current = tools[key]
|
||||
if (!current) return undefined
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append argument text to a tool that must already have been started. This keeps
|
||||
* protocols honest when their stream grammar promises a start event before any
|
||||
@@ -170,12 +190,7 @@ export const appendExisting = <K extends StreamKey>(
|
||||
key: K,
|
||||
text: string,
|
||||
missingToolMessage: string,
|
||||
): AppendOutcome<K> | AIError => {
|
||||
const current = tools[key]
|
||||
if (!current) return eventError(route, missingToolMessage)
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
): AppendOutcome<K> | AIError => append(tools, key, text) ?? eventError(route, missingToolMessage)
|
||||
|
||||
/**
|
||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -39,6 +39,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
@@ -102,7 +103,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
@@ -168,7 +169,7 @@ const config = (settings: Settings): Config => {
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
CompactOperation
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
@@ -132,10 +132,11 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof OpenAIResponses.route.compact
|
||||
>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: XAIResponses.compact,
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,10 +103,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
+121
-42
@@ -14,6 +14,7 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -38,7 +39,7 @@ export interface RouteBody<Body> {
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
readonly id: string
|
||||
@@ -53,7 +54,15 @@ export interface Route<
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
): Route<Body, Prepared, Next>
|
||||
(
|
||||
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
|
||||
): Route<Body, Prepared, Compact>
|
||||
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
|
||||
}
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
@@ -74,7 +83,7 @@ export interface Route<
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
@@ -101,6 +110,7 @@ export interface RouteDefaultsInput {
|
||||
}
|
||||
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
@@ -111,7 +121,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
@@ -162,10 +172,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly compact: CompactMethod
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -189,12 +196,64 @@ export type CompactOperation = (
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
export type TriggerCompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options: TriggerCompactOptions,
|
||||
) => Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
|
||||
/** Protocol capabilities, not deployment/model eligibility. */
|
||||
export interface CompactionOperations {
|
||||
readonly endpoint?: CompactOperation
|
||||
readonly trigger?: TriggerCompactOperation
|
||||
}
|
||||
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
|
||||
readonly mechanism?: "endpoint"
|
||||
readonly webSocket?: never
|
||||
}
|
||||
|
||||
export interface TriggerCompactOptions extends StreamOptions {
|
||||
readonly mechanism: "trigger"
|
||||
}
|
||||
|
||||
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
|
||||
}
|
||||
export type CheckpointRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
|
||||
}
|
||||
|
||||
export interface CompactMethod<R = never> {
|
||||
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
|
||||
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
|
||||
}
|
||||
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options?: { readonly mechanism?: "endpoint" },
|
||||
): request is CompactionRequest
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options: { readonly mechanism: "trigger" },
|
||||
): request is CheckpointRequest
|
||||
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
|
||||
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
|
||||
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
|
||||
return request.model.route.compact?.endpoint !== undefined
|
||||
}
|
||||
|
||||
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
|
||||
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
|
||||
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
|
||||
})
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
@@ -216,7 +275,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
readonly compact?: CompactionOperations
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -238,7 +297,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
readonly compact?: CompactionOperations
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -326,9 +385,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
compact: "compact" in patch ? compact : routeInput.compact,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
@@ -343,7 +403,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -440,12 +500,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, Prepared, Compact>
|
||||
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -487,10 +547,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
}
|
||||
|
||||
const prepareRequest = (request: LLMRequest) => {
|
||||
const original = applyCachePolicy(resolveRequestOptions(request))
|
||||
const original = resolveRequestOptions(request)
|
||||
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
|
||||
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
|
||||
// Deduplicate per sibling level; a tool and a namespace may share a name.
|
||||
const dedupe = (tools: LLMRequest["tools"]): LLMRequest["tools"] =>
|
||||
[...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) =>
|
||||
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
|
||||
)
|
||||
const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) }))
|
||||
const headers = resolved.model.route.headers?.({ request: resolved })
|
||||
return headers === undefined
|
||||
? resolved
|
||||
@@ -557,14 +621,23 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export const compact = (
|
||||
export function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
|
||||
export function compact(
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service>
|
||||
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.compact(request, options)
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return yield* client.compact(request, options)
|
||||
return yield* unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
@@ -578,21 +651,27 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options))
|
||||
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
|
||||
return unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
})
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
compact,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,12 @@ export type {
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
CompactionRequest,
|
||||
CheckpointRequest,
|
||||
EndpointCompactOptions,
|
||||
TriggerCompactOptions,
|
||||
} from "./client.js"
|
||||
export * from "./executor.js"
|
||||
export { Auth } from "./auth.js"
|
||||
|
||||
@@ -97,6 +97,21 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
|
||||
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
|
||||
"LLM.CompactionCheckpointResponse",
|
||||
)({
|
||||
checkpoint: CompactionPart.pipe(
|
||||
Schema.refine(
|
||||
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
|
||||
part.encrypted !== undefined && part.encrypted.length > 0,
|
||||
{ message: "A checkpoint response requires encrypted compaction content" },
|
||||
),
|
||||
),
|
||||
responseID: Schema.String.check(Schema.isPattern(/\S/)),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
@@ -155,6 +170,7 @@ export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
@@ -164,6 +180,7 @@ export const ToolInputDelta = Schema.Struct({
|
||||
type: Schema.tag("tool-input-delta"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
text: Schema.String,
|
||||
/** Best-effort parse of all input fragments received through this delta. */
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
@@ -174,6 +191,7 @@ export const ToolInputEnd = Schema.Struct({
|
||||
type: Schema.tag("tool-input-end"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
@@ -183,6 +201,7 @@ export const ToolInputError = Schema.Struct({
|
||||
type: Schema.tag("tool-input-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
raw: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||
@@ -191,6 +210,7 @@ export const ToolCall = Schema.Struct({
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -201,6 +221,7 @@ export const ToolResult = Schema.Struct({
|
||||
type: Schema.tag("tool-result"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
result: ToolResultValue,
|
||||
output: Schema.optional(ToolOutput),
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -212,6 +233,7 @@ export const ToolError = Schema.Struct({
|
||||
type: Schema.tag("tool-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect()),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -385,6 +407,7 @@ interface ContentAssembly {
|
||||
|
||||
interface ToolInputAssembly {
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
@@ -522,12 +545,17 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
|
||||
...state,
|
||||
toolInputs: {
|
||||
...state.toolInputs,
|
||||
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
|
||||
[event.id]: {
|
||||
name: event.name,
|
||||
namespace: event.namespace,
|
||||
text: "",
|
||||
providerMetadata: event.providerMetadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => {
|
||||
const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
|
||||
const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" }
|
||||
return {
|
||||
...state,
|
||||
toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } },
|
||||
@@ -535,7 +563,7 @@ const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): Resp
|
||||
}
|
||||
|
||||
const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => {
|
||||
const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
|
||||
const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" }
|
||||
return {
|
||||
...state,
|
||||
toolInputs: {
|
||||
@@ -543,6 +571,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
||||
[event.id]: {
|
||||
...current,
|
||||
name: event.name,
|
||||
namespace: event.namespace,
|
||||
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
||||
},
|
||||
},
|
||||
@@ -553,6 +582,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
ToolCallPart.make({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
namespace: event.namespace,
|
||||
input: event.input,
|
||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
|
||||
@@ -562,6 +592,7 @@ const toolResultContent = (event: ToolResult): ContentPart =>
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
namespace: event.namespace,
|
||||
result: event.result,
|
||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
|
||||
|
||||
@@ -135,6 +135,7 @@ export const ToolCallPart = Object.assign(
|
||||
type: Schema.Literal("tool-call"),
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
@@ -152,6 +153,7 @@ export const ToolResultPart = Object.assign(
|
||||
type: Schema.Literal("tool-result"),
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
@@ -168,6 +170,7 @@ export const ToolResultPart = Object.assign(
|
||||
type: "tool-result",
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
namespace: input.namespace,
|
||||
result: ToolResultValue.make(input.result, input.resultType),
|
||||
providerExecuted: input.providerExecuted,
|
||||
cache: input.cache,
|
||||
@@ -266,7 +269,7 @@ export namespace Message {
|
||||
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
|
||||
}
|
||||
|
||||
export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
|
||||
const toolDefinitionFields = {
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
inputSchema: JsonSchema,
|
||||
@@ -274,15 +277,71 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
}
|
||||
|
||||
export type ToolDefinitionInput = Schema.Struct.Type<typeof toolDefinitionFields>
|
||||
|
||||
export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
|
||||
type: Schema.Literal("tool"),
|
||||
...toolDefinitionFields,
|
||||
}) {
|
||||
constructor(input: ToolDefinitionInput) {
|
||||
super({ ...input, type: "tool" })
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ToolDefinition {
|
||||
export type Input = ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]
|
||||
export type Input = ToolDefinition | ToolDefinitionInput
|
||||
|
||||
/** Normalize tool definition input into the canonical `ToolDefinition` class. */
|
||||
export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input))
|
||||
}
|
||||
|
||||
export type ToolNamespace = {
|
||||
readonly type: "namespace"
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly tools: ReadonlyArray<ToolEntry>
|
||||
}
|
||||
|
||||
export type ToolNamespaceInput = Omit<ToolNamespace, "type" | "tools"> & {
|
||||
readonly tools: ReadonlyArray<ToolEntryInput>
|
||||
}
|
||||
export type ToolNamespaceEntryInput = ToolNamespaceInput & { readonly type: "namespace" }
|
||||
|
||||
export const ToolNamespace: Schema.Codec<ToolNamespace> & {
|
||||
readonly make: (input: ToolNamespace | ToolNamespaceInput) => ToolNamespace
|
||||
} = Object.assign(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("namespace"),
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.UndefinedOr(Schema.String)),
|
||||
tools: Schema.Array(Schema.suspend((): Schema.Codec<ToolEntry> => ToolEntry)),
|
||||
}).annotate({ identifier: "LLM.ToolNamespace" }),
|
||||
{
|
||||
make: (input: ToolNamespace | ToolNamespaceInput): ToolNamespace => ({
|
||||
...input,
|
||||
type: "namespace",
|
||||
tools: input.tools.map(ToolEntry.make),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
export type ToolEntry = ToolDefinition | ToolNamespace
|
||||
export type ToolEntryInput = ToolDefinition.Input | ToolNamespaceEntryInput
|
||||
export const ToolEntry: Schema.Codec<ToolEntry> & {
|
||||
readonly make: (input: ToolEntryInput) => ToolEntry
|
||||
} = Object.assign(
|
||||
Schema.Union([ToolDefinition, ToolNamespace]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
Schema.annotate({ identifier: "LLM.ToolEntry" }),
|
||||
),
|
||||
{
|
||||
make: (input: ToolEntryInput): ToolEntry =>
|
||||
"type" in input && input.type === "namespace" ? ToolNamespace.make(input) : ToolDefinition.make(input),
|
||||
},
|
||||
)
|
||||
|
||||
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
|
||||
type: Schema.Literals(["auto", "none", "required", "tool"]),
|
||||
name: Schema.optional(Schema.String),
|
||||
@@ -312,7 +371,7 @@ const requestSchema = Schema.Struct({
|
||||
model: LanguageModelSchema,
|
||||
system: Schema.Array(SystemPart),
|
||||
messages: Schema.Array(Message),
|
||||
tools: Schema.Array(ToolDefinition),
|
||||
tools: Schema.Array(ToolEntry),
|
||||
toolChoice: Schema.optional(ToolChoice),
|
||||
generation: Schema.optional(GenerationOptions),
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import type { AnyRoute, CompactionOperations } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
@@ -194,7 +194,7 @@ export class LanguageModel<
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
id: ModelID.make(input.id),
|
||||
@@ -206,7 +206,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
return {
|
||||
@@ -218,11 +218,11 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
@@ -241,7 +241,7 @@ export class LanguageModel<
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
@@ -249,7 +249,7 @@ export namespace LanguageModel {
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
|
||||
+39
-11
@@ -1,10 +1,17 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMClient,
|
||||
type CompactionRequest,
|
||||
type CheckpointRequest,
|
||||
type EndpointCompactOptions,
|
||||
type TriggerCompactOptions,
|
||||
} from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -13,7 +20,11 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
export type Response =
|
||||
| readonly LLMEvent[]
|
||||
| Stream.Stream<LLMEvent, AIError>
|
||||
| CompactionResponse
|
||||
| CompactionCheckpointResponse
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -132,21 +143,38 @@ const make = (options: LayerOptions) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse)
|
||||
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const test = Test.of({
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(
|
||||
request: LLMRequest,
|
||||
options?: EndpointCompactOptions | TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
|
||||
return take(request).pipe(
|
||||
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
|
||||
if (options?.mechanism === "trigger")
|
||||
return response instanceof CompactionCheckpointResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
|
||||
return response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse")
|
||||
}),
|
||||
)
|
||||
}
|
||||
const test = Test.of({
|
||||
compact,
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -37,7 +37,13 @@ function missingToolResults(calls: Iterable<ToolCallPart>) {
|
||||
return new Message({
|
||||
role: "tool",
|
||||
content: [...calls].map((call) =>
|
||||
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
|
||||
ToolResultPart.make({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
namespace: call.namespace,
|
||||
result: MISSING_TOOL_RESULT,
|
||||
resultType: "error",
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -47,7 +53,7 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
|
||||
if (part.type !== "tool-result" || part.providerExecuted === true) return part
|
||||
const call = pending.get(part.id)
|
||||
if (call) pending.delete(part.id)
|
||||
return normalizeToolResult(part, call?.name ?? part.name)
|
||||
return normalizeToolResult(part, call)
|
||||
})
|
||||
if (content.length === 0) return undefined
|
||||
if (content.every((part, index) => part === message.content[index])) return message
|
||||
@@ -61,8 +67,11 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
|
||||
const named = part.name === name ? part : { ...part, name }
|
||||
function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefined): ToolResultPart {
|
||||
const named =
|
||||
call === undefined || (part.name === call.name && part.namespace === call.namespace)
|
||||
? part
|
||||
: { ...part, name: call.name, namespace: call.namespace }
|
||||
if (named.result.type === "text" && named.result.value === "")
|
||||
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (named.result.type === "error" && named.result.value === "")
|
||||
|
||||
@@ -21,10 +21,11 @@ export interface DispatchResult extends ToolSettlement {
|
||||
|
||||
/** Execute one canonical tool call without owning provider IO or continuation. */
|
||||
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
|
||||
const tool = tools[call.name]
|
||||
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` }))
|
||||
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
|
||||
const tool = tools[name]
|
||||
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
|
||||
if (!tool.execute)
|
||||
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` }))
|
||||
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
|
||||
|
||||
return decodeAndExecute(tool, call).pipe(
|
||||
Effect.map((value) => result(call, value)),
|
||||
@@ -38,7 +39,11 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
|
||||
tool._decode(call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((decoded) =>
|
||||
tool.execute!(decoded, { id: call.id, name: call.name }).pipe(
|
||||
tool.execute!(decoded, {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
namespace: call.namespace,
|
||||
}).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
tool._encode(value).pipe(
|
||||
Effect.mapError(
|
||||
@@ -71,6 +76,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
LLMEvent.toolError({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
namespace: call.namespace,
|
||||
message: String(settlement.result.value),
|
||||
error,
|
||||
providerMetadata: call.providerMetadata,
|
||||
@@ -78,6 +84,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
LLMEvent.toolResult({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
namespace: call.namespace,
|
||||
result: settlement.result,
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
@@ -86,6 +93,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
LLMEvent.toolResult({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
namespace: call.namespace,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
providerMetadata: call.providerMetadata,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ToolSchema<T> = Schema.Codec<T, any, never, never>
|
||||
export interface ToolExecuteContext {
|
||||
readonly id: ToolCallPart["id"]
|
||||
readonly name: ToolCallPart["name"]
|
||||
readonly namespace?: ToolCallPart["namespace"]
|
||||
}
|
||||
|
||||
export type ToolExecute<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
|
||||
|
||||
@@ -215,6 +215,35 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("deduplicates tools before counting cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const manual = new CacheHint({ type: "ephemeral" })
|
||||
const duplicate = (description: string) => ({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object" },
|
||||
cache: manual,
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
tools: [
|
||||
duplicate("first"),
|
||||
duplicate("second"),
|
||||
duplicate("third"),
|
||||
duplicate("fourth"),
|
||||
{ name: "lookup", description: "final", inputSchema: { type: "object" } },
|
||||
],
|
||||
cache: { tools: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
expect.objectContaining({ name: "lookup", description: "final", cache_control: { type: "ephemeral" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("auto policy preserves manual CacheHints on other parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -281,6 +310,30 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
test("marks the final leaf inside a tool namespace", () => {
|
||||
const request = LLM.request({
|
||||
model: anthropicModel,
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
tools: [
|
||||
{ name: "lookup", description: "lookup", inputSchema: {} },
|
||||
{ name: "orders", description: "orders", inputSchema: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
cache: { tools: true },
|
||||
})
|
||||
const applied = applyCachePolicy(request)
|
||||
const namespace = applied.tools[0]
|
||||
|
||||
expect(namespace?.type).toBe("namespace")
|
||||
if (namespace?.type !== "namespace") throw new Error("Expected namespace")
|
||||
expect(namespace.tools[0]).not.toHaveProperty("cache")
|
||||
expect(namespace.tools[1]).toHaveProperty("cache", { type: "ephemeral" })
|
||||
})
|
||||
|
||||
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
|
||||
import {
|
||||
LLM,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolNamespace,
|
||||
mergeProviderOptions,
|
||||
} from "../src/index.js"
|
||||
import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
|
||||
import { Auth, LLMClient } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -106,6 +114,58 @@ describe("request option precedence", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("deduplicates tools within each namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAIResponses.route.model({ id: "gpt-5.4" }),
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "crm", description: "Top-level CRM tool", inputSchema: {} }),
|
||||
ToolNamespace.make({
|
||||
name: "crm",
|
||||
description: "CRM tools",
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: {} }),
|
||||
ToolDefinition.make({ name: "search", description: "search", inputSchema: {} }),
|
||||
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: {} }),
|
||||
],
|
||||
}),
|
||||
ToolNamespace.make({
|
||||
name: "support",
|
||||
description: "Support tools",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "support", inputSchema: {} })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "crm",
|
||||
description: "Top-level CRM tool",
|
||||
parameters: {},
|
||||
strict: false,
|
||||
},
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
description: "CRM tools",
|
||||
tools: [
|
||||
{ type: "function", name: "lookup", description: "new", parameters: {}, strict: false },
|
||||
{ type: "function", name: "search", description: "search", parameters: {}, strict: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "namespace",
|
||||
name: "support",
|
||||
description: "Support tools",
|
||||
tools: [{ type: "function", name: "lookup", description: "support", parameters: {}, strict: false }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes tool history before protocol lowering", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { CacheHint, LLM, LLMResponse } from "../src/index.js"
|
||||
import { Schema } from "effect"
|
||||
import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
||||
import {
|
||||
@@ -17,6 +18,52 @@ const chatRoute = OpenAIChat.route
|
||||
const responsesRoute = OpenAIResponses.route
|
||||
|
||||
describe("llm constructors", () => {
|
||||
test("normalizes recursive tool namespaces", () => {
|
||||
const request = LLM.request({
|
||||
model: LanguageModel.make({ id: "fake-model", provider: "fake", route: responsesRoute }),
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: [
|
||||
{ name: "lookup", description: "Look up a customer", inputSchema: { type: "object" } },
|
||||
{
|
||||
type: "namespace",
|
||||
name: "orders",
|
||||
tools: [{ name: "list", description: "List orders", inputSchema: { type: "object" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(request.tools[0]).toEqual({
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: [
|
||||
expect.objectContaining({ type: "tool", name: "lookup" }),
|
||||
{
|
||||
type: "namespace",
|
||||
name: "orders",
|
||||
description: undefined,
|
||||
tools: [expect.objectContaining({ type: "tool", name: "list" })],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(request.tools[0]).toEqual(
|
||||
ToolNamespace.make({
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: request.tools[0]!.type === "namespace" ? request.tools[0].tools : [],
|
||||
}),
|
||||
)
|
||||
expect(Schema.decodeUnknownSync(ToolEntry)(Schema.encodeUnknownSync(ToolEntry)(request.tools[0]))).toEqual(
|
||||
request.tools[0],
|
||||
)
|
||||
})
|
||||
|
||||
test("builds canonical schema classes from ergonomic input", () => {
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionCheckpointResponse,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
OpenAI,
|
||||
OpenAICompatibleResponses,
|
||||
XAI,
|
||||
} from "../../src/providers.js"
|
||||
import type { WebSocketChannelExecutor } from "../../src/route.js"
|
||||
import type { RoutePatch } from "../../src/route/client.js"
|
||||
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
|
||||
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
|
||||
|
||||
declare const webSocket: WebSocketChannelExecutor
|
||||
const model = OpenAI.configure().responses("fixture")
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.map((result) => {
|
||||
result satisfies CompactionCheckpointResponse
|
||||
result.checkpoint.encrypted satisfies string
|
||||
result.responseID satisfies string
|
||||
// @ts-expect-error A trigger does not return replacement history.
|
||||
result.replacement
|
||||
}),
|
||||
)
|
||||
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
|
||||
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
|
||||
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
|
||||
LLMClient.compact(request, { webSocket })
|
||||
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
|
||||
LLMClient.compact(request, { mechanism: "other" })
|
||||
|
||||
for (const selected of [
|
||||
model,
|
||||
OpenAI.model("fixture", {}),
|
||||
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.make(LanguageModel.input(model)),
|
||||
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
|
||||
}
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
|
||||
|
||||
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
|
||||
const xai = XAI.configure().responses("fixture")
|
||||
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
|
||||
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
|
||||
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error xAI endpoint support does not imply trigger support.
|
||||
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
|
||||
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
|
||||
|
||||
const unsupported = {
|
||||
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
|
||||
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
|
||||
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Bedrock does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
|
||||
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
|
||||
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
|
||||
// @ts-expect-error Anthropic does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
|
||||
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Azure Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
|
||||
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the model replaces its capability.
|
||||
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the route replaces its capability.
|
||||
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
|
||||
}),
|
||||
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
|
||||
{ mechanism: "trigger" },
|
||||
)
|
||||
|
||||
declare const dynamic: LLMRequest
|
||||
declare const patch: Partial<LLMRequest.Input>
|
||||
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
|
||||
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Explicitly removing operations removes trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Dynamic models must be narrowed.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
|
||||
}
|
||||
if (LLMClient.canCompact(dynamic)) {
|
||||
LLMClient.compact(dynamic)
|
||||
// @ts-expect-error Endpoint narrowing does not grant trigger support.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
}
|
||||
// @ts-expect-error A dynamic model override cannot preserve trigger support.
|
||||
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
|
||||
@@ -713,9 +713,10 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores late tool deltas after contentBlockStop", () =>
|
||||
it.effect("ignores tool deltas without an open tool block", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 5, delta: { toolUse: { input: "{}" } } }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
@@ -745,27 +746,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool deltas without contentBlockStart", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Bedrock Converse tool delta is missing its tool call",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers incomplete tool input at finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest } from "../../src/index.js"
|
||||
import { Azure, OpenAI } from "../../src/providers.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
|
||||
const trigger = { mechanism: "trigger" } as const
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toBe("/v1/responses")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
expect(request.headers.authorization).toBe("Bearer fixture")
|
||||
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
|
||||
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
|
||||
const body = JSON.parse(text)
|
||||
expect(body).toMatchObject({
|
||||
model: "fixture",
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: "Keep the context",
|
||||
parallel_tool_calls: true,
|
||||
prompt_cache_key: "session-key",
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
|
||||
})
|
||||
expect(body.tools).toHaveLength(1)
|
||||
expect(body.tools[0].name).toBe("lookup")
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
expect(body.context_management).toBeUndefined()
|
||||
expect(body.text).toBeUndefined()
|
||||
expect(body.max_output_tokens).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
return respond(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [checkpoint],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
input_tokens_details: { cached_tokens: 40 },
|
||||
output_tokens: 5,
|
||||
total_tokens: 105,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const input = LLM.request({
|
||||
model: request.model,
|
||||
system: "Keep the context",
|
||||
prompt: "hello",
|
||||
promptCacheKey: "session-key",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: { type: "tool", name: "lookup" },
|
||||
generation: { maxTokens: 1 },
|
||||
providerOptions: {
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
contextManagement: [{ type: "compaction" }],
|
||||
},
|
||||
http: {
|
||||
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
service_tier: "priority",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
store: true,
|
||||
stream: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
tool_choice: "required",
|
||||
},
|
||||
},
|
||||
})
|
||||
const original = LLMRequest.input(input)
|
||||
const result = yield* LLMClient.compact(input, {
|
||||
...trigger,
|
||||
http: (request, next) => {
|
||||
calls.push("http")
|
||||
return next(request)
|
||||
},
|
||||
})
|
||||
expect(result.checkpoint).toMatchObject({
|
||||
type: "compaction",
|
||||
provider: "openai",
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
expect(result.responseID).toBe("resp_1")
|
||||
expect(result.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 5,
|
||||
totalTokens: 105,
|
||||
cacheReadInputTokens: 40,
|
||||
})
|
||||
expect(LLMRequest.input(input)).toEqual(original)
|
||||
expect(calls).toEqual(["http"])
|
||||
}),
|
||||
)
|
||||
|
||||
const idless = { type: "compaction", encrypted_content: "opaque" }
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
|
||||
},
|
||||
{ type: "response.output_text.delta", delta: "do not expose this" },
|
||||
{ type: "response.output_item.added", output_index: 1, item: { type: "compaction" } },
|
||||
{ type: "response.output_item.done", output_index: 1, item: idless },
|
||||
{ type: "response.output_item.done", output_index: 1, item: idless },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, idless],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
).effect("correlates an ID-less checkpoint across repeated events and ignores other output", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.checkpoint.id).toBeString()
|
||||
expect("message" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, events] of Object.entries({
|
||||
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
|
||||
multiple: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
|
||||
],
|
||||
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
|
||||
malformed: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
|
||||
],
|
||||
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
|
||||
incomplete: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
|
||||
],
|
||||
failed: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
|
||||
],
|
||||
})) {
|
||||
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
|
||||
testEffect(
|
||||
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
|
||||
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
|
||||
"rejects clean EOF without response.completed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
"endpoint support does not grant trigger support, including for untyped callers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* LLMClient.Service
|
||||
const unsupported = LLM.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") })
|
||||
expect(LLMClient.canCompact(unsupported)).toBe(true)
|
||||
expect(LLMClient.canCompact(unsupported, trigger)).toBe(false)
|
||||
// @ts-expect-error Exercise untyped consumers; the service must reject before sending.
|
||||
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
|
||||
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
|
||||
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(fixedResponse("must not use HTTP")).effect(
|
||||
"invalid checkpoint output is rejected before acknowledging channel completion",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
let completed = 0
|
||||
const operation = LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket: {
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: Stream.make(
|
||||
JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [] },
|
||||
}),
|
||||
),
|
||||
complete: Effect.sync(() => {
|
||||
completed++
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const error = yield* operation.pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(completed).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLM, LLMRequest, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient, Route } from "../../src/route/client.js"
|
||||
import { Auth } from "../../src/route/auth.js"
|
||||
import { Endpoint } from "../../src/route/endpoint.js"
|
||||
@@ -134,7 +134,12 @@ for (const model of [
|
||||
[
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
|
||||
ToolDefinition.make({
|
||||
name: "unsupported",
|
||||
description: "Generation only",
|
||||
inputSchema: {},
|
||||
native: { unsupported: {} },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
"InvalidRequest",
|
||||
|
||||
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
it.effect("closes implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
delta: "Third",
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
|
||||
completed,
|
||||
)
|
||||
|
||||
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
it.effect("preserves done-only reasoning text and encryption", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
|
||||
completed,
|
||||
// Route termination must also prevent events after response completion.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
|
||||
@@ -337,7 +326,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("mints an id for a done-only tool that never had one", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
@@ -368,9 +356,16 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
const events = yield* collect({ type: "response.output_item.done", item }, completed)
|
||||
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", namespace: undefined, providerMetadata },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", namespace: undefined, providerMetadata },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
namespace: undefined,
|
||||
input: { query: "weather" },
|
||||
providerMetadata,
|
||||
},
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
|
||||
@@ -171,6 +171,66 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flattens tool namespaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "acme",
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "billing",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup billing", inputSchema: {} })],
|
||||
},
|
||||
ToolDefinition.make({ name: "users", description: "Lookup users", inputSchema: {} }),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "acme_billing_lookup",
|
||||
description: "Lookup billing",
|
||||
parameters: {},
|
||||
strict: false,
|
||||
},
|
||||
{ type: "function", name: "acme_users", description: "Lookup users", parameters: {}, strict: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flattens tool namespaces in history", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }),
|
||||
Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "function_call", call_id: "call_1", name: "crm_lookup", namespace: undefined, arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call_1", output: "done" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers canonical parallel tool control", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LanguageModel,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolNamespace,
|
||||
ToolResultPart,
|
||||
TransportError,
|
||||
Usage,
|
||||
@@ -143,6 +144,94 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers tool namespaces without flattening leaf names", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Find a customer and their orders.",
|
||||
tools: [
|
||||
ToolNamespace.make({
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "Look up a customer", inputSchema: {} }),
|
||||
ToolDefinition.make({ name: "orders", description: "List customer orders", inputSchema: {} }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: [
|
||||
{ type: "function", name: "lookup", description: "Look up a customer", parameters: {}, strict: false },
|
||||
{ type: "function", name: "orders", description: "List customer orders", parameters: {}, strict: false },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flattens nested levels within a native tool namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "orders",
|
||||
description: "Order management",
|
||||
tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
description: "Customer management",
|
||||
tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults tool namespace descriptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "crm",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Look up a customer", inputSchema: {} })],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
expect.objectContaining({ type: "namespace", name: "crm", description: "Tools in the crm namespace." }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid hosted image generation options locally", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
@@ -568,7 +657,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after the completed assistant output", () =>
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
@@ -582,7 +671,7 @@ describe("OpenAI Responses route", () => {
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -610,42 +699,37 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues store-false reasoning while retaining the output item ID", () =>
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
|
||||
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
}
|
||||
const first = continuationDriver(request)
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
item: reasoning,
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const next = continuationDriver({
|
||||
...request,
|
||||
input: [
|
||||
...firstInput,
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
],
|
||||
input: [...firstInput, reasoning, { role: "user", content: [{ type: "input_text", text: "Continue" }] }],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
@@ -655,6 +739,11 @@ describe("OpenAI Responses route", () => {
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
|
||||
})
|
||||
const edited = yield* continuationDriver({
|
||||
...request,
|
||||
input: [...firstInput, { ...reasoning, encrypted_content: "edited" }, { role: "user", content: "Continue" }],
|
||||
}).create(saved)
|
||||
expect(edited.mode).toBe("full")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2130,6 +2219,71 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves tool namespaces through streaming and history replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
namespace: "crm",
|
||||
name: "lookup",
|
||||
arguments: "",
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
item_id: "fc_1",
|
||||
delta: '{"id":"123"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { ...item, arguments: '{"id":"123"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const toolEvents = response.events.filter((event) => event.type.startsWith("tool-"))
|
||||
expect(toolEvents).toEqual([
|
||||
expect.objectContaining({ type: "tool-input-start", name: "lookup", namespace: "crm" }),
|
||||
expect.objectContaining({ type: "tool-input-delta", name: "lookup", namespace: "crm" }),
|
||||
expect.objectContaining({ type: "tool-input-end", name: "lookup", namespace: "crm" }),
|
||||
expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }),
|
||||
])
|
||||
expect(response.message.content).toEqual([
|
||||
expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }),
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: { customer: "Ada" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
namespace: "crm",
|
||||
name: "lookup",
|
||||
arguments: '{"id":"123"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"customer":"Ada"}' },
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("routes reasoning summary events by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -2861,7 +3015,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -79,6 +80,31 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("returns scripted checkpoints only for trigger compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
|
||||
const checkpoint = new CompactionCheckpointResponse({
|
||||
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
|
||||
responseID: "resp_fixture",
|
||||
})
|
||||
const endpoint = new CompactionResponse({ replacement: [] })
|
||||
yield* client.push(checkpoint, endpoint, checkpoint, checkpoint)
|
||||
const operation = LLMClient.compact(request, { mechanism: "trigger" })
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
expect(yield* operation).toBe(checkpoint)
|
||||
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
|
||||
)
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
|
||||
@@ -15,13 +15,7 @@ describe("tool history normalization", () => {
|
||||
Message.assistant(toolCall("trailing")),
|
||||
])
|
||||
|
||||
expect(normalized.map((message) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"tool",
|
||||
"tool",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(normalized.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user", "assistant"])
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
|
||||
expect(normalized[2]?.content).toEqual([
|
||||
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
|
||||
@@ -74,4 +68,13 @@ describe("tool history normalization", () => {
|
||||
|
||||
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
|
||||
})
|
||||
|
||||
test("uses a matching call as the complete tool identity", () => {
|
||||
const normalized = normalizeToolHistory([
|
||||
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
|
||||
Message.tool(ToolResultPart.make({ id: "call_1", name: "wrong", namespace: "stale", result: "done" })),
|
||||
])
|
||||
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolOutput,
|
||||
toDefinitions,
|
||||
@@ -36,6 +37,27 @@ const baseRequest = LLM.request({
|
||||
})
|
||||
const weatherFailureCause = new Error("weather lookup denied")
|
||||
|
||||
test("dispatches namespaced calls by qualified identity", async () => {
|
||||
let context: ToolExecuteContext | undefined
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a customer.",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.String,
|
||||
execute: (_, value) => {
|
||||
context = value
|
||||
return Effect.succeed("customer")
|
||||
},
|
||||
})
|
||||
const call = ToolCallPart.make({ id: "call_1", namespace: "crm", name: "lookup", input: {} })
|
||||
const result = await Effect.runPromise(
|
||||
ToolRuntime.dispatch({ "crm.lookup": lookup, lookup: schema_only_weather }, call),
|
||||
)
|
||||
|
||||
expect(result.result).toEqual({ type: "text", value: "customer" })
|
||||
expect(context).toEqual({ id: "call_1", namespace: "crm", name: "lookup" })
|
||||
expect(result.events).toEqual([expect.objectContaining({ type: "tool-result", namespace: "crm", name: "lookup" })])
|
||||
})
|
||||
|
||||
const get_weather = Tool.make({
|
||||
description: "Get current weather for a city.",
|
||||
parameters: Schema.Struct({ city: Schema.String }),
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
|
||||
}
|
||||
|
||||
@@ -88,27 +88,88 @@ story("renders a draft once and supports editing, caret restoration, and failure
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
await component.getByRole("status").click()
|
||||
await page.mouse.move(0, 0)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).toHaveText("balanced")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await component.getByRole("status").click()
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await expect(control).toHaveCSS("pointer-events", "none")
|
||||
|
||||
await input.hover()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await expect(control).toHaveCSS("pointer-events", "auto")
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(input).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
await input.hover()
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await expect(high).toBeVisible()
|
||||
await high.click()
|
||||
await component.getByRole("status").click()
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
|
||||
await input.focus()
|
||||
await expect(control).toBeVisible()
|
||||
await input.blur()
|
||||
await expect(control).toBeVisible()
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await component.getByRole("status").click()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
})
|
||||
|
||||
story("keeps default thinking accessible by keyboard without composer hover", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const control = component.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
// Tab through Add, Agent, and Model to the visually hidden thinking trigger.
|
||||
for (let count = 0; count < 4; count++) await page.keyboard.press("Tab")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(control).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("End")
|
||||
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).not.toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
|
||||
const component = await mount("ui-split-button--open-in", { globals: { theme } })
|
||||
const control = component.locator('[data-component="split-button-v2"]')
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).not.toHaveCSS("box-shadow", "none")
|
||||
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
|
||||
|
||||
await component.getByRole("button", { name: "Open options" }).hover()
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { benchmark, expect } from "./benchmark"
|
||||
import { openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
trace: "off",
|
||||
video: "off",
|
||||
})
|
||||
|
||||
for (const home of [false, true]) {
|
||||
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
|
||||
const { dialog, input } = await openCommandPalette(page, home)
|
||||
const title = home ? "Open settings" : "Copy Session ID"
|
||||
const query = home ? "open settings" : "copy session"
|
||||
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
|
||||
await input.evaluate((element, title) => {
|
||||
element.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
performance.mark("palette-input")
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
|
||||
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
|
||||
if (!selected?.textContent?.includes(title)) return
|
||||
performance.measure("palette-result", "palette-input")
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
)
|
||||
}, title)
|
||||
await input.fill(query)
|
||||
await expect(dialog.getByRole("option")).toHaveCount(1)
|
||||
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
const result = await page.evaluate(() =>
|
||||
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const palette = await openCommandPalette(page)
|
||||
const path = `**/api/session/${paletteSession.id}`
|
||||
await page.route(path, (route) => route.abort("failed"))
|
||||
const requested = page.waitForRequest(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_failed_refresh",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, idle: 2 },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await requested
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await palette.input.fill("copy session")
|
||||
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await palette.input.press("Escape")
|
||||
await page.unroute(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_recovered_refresh",
|
||||
created: 3,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: sessionID, seq: 2, version: 1 },
|
||||
data: { sessionID, title: "Recovered session" },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await input.pressSequentially("copy session")
|
||||
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
|
||||
await expect(copy).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
|
||||
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("home commands do not wait for session search", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page, true)
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await input.fill("open settings")
|
||||
await expect(dialog.getByRole("option")).toHaveCount(1)
|
||||
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("appends search results without resetting the selected command", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const files = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", async (route) => {
|
||||
await files.promise
|
||||
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
|
||||
})
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("copy")
|
||||
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
|
||||
await expect(project).toBeVisible()
|
||||
// Select a non-first command with the keyboard before remote results arrive.
|
||||
await input.press("ArrowDown")
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
files.resolve()
|
||||
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
// File results are usable even while sessions are still pending.
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
await input.fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
|
||||
)
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("README")
|
||||
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", video: "off" })
|
||||
|
||||
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page)
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
json: { data: [{ path: "fixture.txt", type: "file" }] },
|
||||
}),
|
||||
)
|
||||
await palette.input.fill("fixture.txt")
|
||||
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
|
||||
const file = page.getByRole("tab", { name: /fixture\.txt/ })
|
||||
await expect(file).toBeVisible()
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("complementary", { name: "Review and files" })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(file).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page, true)
|
||||
await palette.input.press("Escape")
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("region", { name: "Recent sessions" })
|
||||
.getByRole("button", { name: /Palette fixture session/ })
|
||||
.click()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?/)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.locator('[data-component="composer-editor"]').blur()
|
||||
await page.keyboard.press("Control+l")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
|
||||
await dialog.getByRole("textbox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
for (const count of [3, 4]) {
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
}
|
||||
await page.setViewportSize({ width: 600, height: 800 })
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/slash-skills"
|
||||
const projectID = "proj_slash_skills"
|
||||
const sessionID = "ses_slash_skills"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
async function setup(page: Page, queued = false) {
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "slash-skills",
|
||||
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,
|
||||
projectID,
|
||||
directory,
|
||||
title: "Slash skills",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
|
||||
inbox: queued
|
||||
? [
|
||||
{
|
||||
id: "inb_slash_skill",
|
||||
sessionID,
|
||||
timeCreated: 1700000000000,
|
||||
type: "user",
|
||||
payload: { text: "Explain caching" },
|
||||
delivery: "queue",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
findFiles: ({ query }) =>
|
||||
query.includes("cache")
|
||||
? [
|
||||
{
|
||||
name: "cache.ts",
|
||||
path: "src/cache.ts",
|
||||
absolute: `${directory}/src/cache.ts`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
onPrompt: ({ body }) => prompts.push(body),
|
||||
})
|
||||
await page.route("**/api/skill?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [
|
||||
{ id: "show-me", slash: true, autoinvoke: false },
|
||||
{ id: "hidden", slash: false },
|
||||
{ id: "implicit" },
|
||||
{ id: "review", slash: true },
|
||||
{ id: "model", slash: true },
|
||||
].map((skill) => ({
|
||||
name: skill.id === "show-me" ? "Show Me" : skill.id,
|
||||
description: "Explain the current topic visually",
|
||||
location: `/skills/${skill.id}/SKILL.md`,
|
||||
content: "Explain visually",
|
||||
...skill,
|
||||
})),
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/command?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const editor = composer.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
return { prompts, composer, editor }
|
||||
}
|
||||
|
||||
for (const selection of ["keyboard", "pointer"]) {
|
||||
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page)
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toContainText("/show-me")
|
||||
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
|
||||
if (selection === "keyboard") await editor.press("Enter")
|
||||
if (selection === "pointer") await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.pressSequentially("explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
await expect(editor).toBeEmpty()
|
||||
})
|
||||
}
|
||||
|
||||
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
|
||||
const { editor } = await setup(page)
|
||||
await editor.fill("/")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
|
||||
for (const id of ["hidden", "implicit", "review", "model"]) {
|
||||
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
|
||||
}
|
||||
await editor.press("Escape")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.fill("@hidden")
|
||||
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
|
||||
await expect(hidden).toContainText("@hidden")
|
||||
await hidden.click()
|
||||
await expect(editor).toHaveText("@hidden ")
|
||||
await expect(editor).toBeFocused()
|
||||
})
|
||||
|
||||
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
|
||||
const { prompts, composer, editor } = await setup(page)
|
||||
await editor.fill("explain @cache")
|
||||
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
|
||||
await expect(file).toBeVisible()
|
||||
await file.click()
|
||||
await expect(editor).toHaveText("explain @src/cache.ts ")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
|
||||
})
|
||||
})
|
||||
|
||||
for (const select of [true, false]) {
|
||||
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page, true)
|
||||
const row = page.locator('[data-component="session-queue-row"]')
|
||||
await row.getByText("Explain caching", { exact: true }).click()
|
||||
await expect(editor).toHaveText("Explain caching")
|
||||
if (select) {
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await editor.pressSequentially("explain caching")
|
||||
}
|
||||
if (!select) await editor.fill("/show-me explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
delivery: "queue",
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_dropzone"
|
||||
const directory = "C:/OpenCode/NewSessionDropzone"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_dropzone",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "new-session-dropzone",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
|
||||
const surface = page.locator('[data-component="new-session"]')
|
||||
const dropzone = page.locator('[data-component="session-dropzone"]')
|
||||
const transfer = await page.evaluateHandle(() => {
|
||||
const value = new DataTransfer()
|
||||
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
|
||||
return value
|
||||
})
|
||||
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
|
||||
await expect(dropzone).toHaveAttribute("data-visible", "true")
|
||||
await expect(dropzone).toContainText("Drop files to add")
|
||||
|
||||
await surface.dispatchEvent("drop", { dataTransfer: transfer })
|
||||
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -25,6 +26,53 @@ for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page, { untitled: true })
|
||||
const label = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tab-title"]'
|
||||
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
|
||||
)
|
||||
await expect(label).toHaveText("Session")
|
||||
const pending = await submitPending(page, mock)
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("pending-tab-title", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await label.click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
}
|
||||
|
||||
mock.events.push({
|
||||
id: "evt_generated_title",
|
||||
type: "session.renamed",
|
||||
created: Date.now(),
|
||||
location: { directory: workspace },
|
||||
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID: pending.sessionID, title: "Generated session title" },
|
||||
})
|
||||
await expect(label).toHaveText("Generated session title")
|
||||
})
|
||||
|
||||
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
@@ -86,7 +134,7 @@ for (const viewport of [
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(pending.title).toHaveText("New session")
|
||||
await expect(pending.title).toHaveText("Session")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
@@ -190,7 +238,7 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
!frame.message ||
|
||||
!frame.spinner ||
|
||||
frame.draft !== followUp ||
|
||||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
!["Session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
),
|
||||
).toEqual([])
|
||||
const after = await title.boundingBox()
|
||||
@@ -335,6 +383,61 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
|
||||
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const expanded =
|
||||
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
|
||||
await page.route("**/api/session/*/command", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
|
||||
commands.push({ sessionID, body: route.request().postDataJSON() })
|
||||
// The server owns command expansion; the client receives the expanded inbox item.
|
||||
events.push({
|
||||
id: "evt_workspace_review",
|
||||
type: "session.inbox.enqueued",
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "msg_workspace_review",
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await route.fulfill({ status: 204, headers })
|
||||
})
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.fill("/review")
|
||||
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/review")
|
||||
const pending = await submitPending(page, mock, "/review latest commit")
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect
|
||||
.poll(() => commands)
|
||||
.toEqual([
|
||||
{
|
||||
sessionID: pending.sessionID,
|
||||
body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
|
||||
},
|
||||
])
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.prompts).toEqual([])
|
||||
await testInfo.attach("expanded-worktree-command", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
})
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.pressSequentially("!")
|
||||
@@ -346,12 +449,16 @@ async function draftFollowUp(page: Page) {
|
||||
await expect(editor).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
|
||||
) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
const worktreeRequests: Record<string, unknown>[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
const project = {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
@@ -378,6 +485,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
events: options?.events ?? (() => events.splice(0)),
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
@@ -404,7 +512,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
|
||||
}
|
||||
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
|
||||
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
|
||||
const session = currentSession(
|
||||
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
|
||||
workspace,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session }, headers })
|
||||
})
|
||||
@@ -436,6 +547,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
if (options?.command) {
|
||||
await page.route("**/api/command?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
}
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -461,11 +583,11 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, worktreeRequests, calls, creates, prompts }
|
||||
return { worktree, worktreeRequests, calls, creates, prompts, events }
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(text)
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(prompt)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
|
||||
@@ -476,12 +598,12 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
const title = preparing.getByRole("heading", { level: 1 })
|
||||
await expect(preparing).toBeVisible()
|
||||
await expect(title).toHaveText("New session")
|
||||
await expect(title).toHaveText("Session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
|
||||
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
|
||||
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
|
||||
await expect(shimmer).toBeVisible()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
|
||||
const sessionID = "ses_prompt_thinking_level_regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows the thinking level control while relevant", async ({ page }) => {
|
||||
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -52,30 +52,57 @@ test("shows the thinking level control while relevant", async ({ page }) => {
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await idleComposer(page)
|
||||
await expect(control).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await expect(control).toHaveCSS("pointer-events", "none")
|
||||
|
||||
await input.hover()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(input).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
await input.hover()
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await expect(high).toBeVisible()
|
||||
await high.click()
|
||||
|
||||
await idleComposer(page)
|
||||
await input.focus()
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await idleComposer(page)
|
||||
await expect(control).toBeVisible()
|
||||
})
|
||||
|
||||
async function idleComposer(page: Page) {
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
}
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
// The single-agent fixture has only Add and Model before the thinking trigger.
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(control).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
|
||||
await mockServers(page)
|
||||
await page.addInitScript(
|
||||
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefB)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
|
||||
// The parent is idle, but its tab remains active while the background child runs.
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
// Both servers stay connected while the client hydrates their active-session snapshots.
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const servers = new Map(
|
||||
[sessionA, sessionB].map(
|
||||
(current) =>
|
||||
[
|
||||
current === sessionA ? serverA : serverB,
|
||||
createMockServerHandler({
|
||||
directory: current.directory,
|
||||
project: {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
sessions: current === sessionB ? [current, childB] : [current],
|
||||
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
page.on("close", () => servers.forEach((server) => void server.dispose()))
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const server = servers.get(url.origin)
|
||||
if (!server) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, {
|
||||
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
|
||||
cursor: {},
|
||||
if (directory && directory !== current.directory)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { name: "InvalidDirectory" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
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 (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: current.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
if (route.request().method() === "OPTIONS")
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
const body = route.request().postDataBuffer()
|
||||
const response = await server.handler(
|
||||
new Request(url, {
|
||||
method: route.request().method(),
|
||||
headers: route.request().headers(),
|
||||
body: body ? Uint8Array.from(body) : undefined,
|
||||
}),
|
||||
)
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-plus",
|
||||
)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const openFileTab = panel.getByRole("tab", { name: "Open file" })
|
||||
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
|
||||
await expect(openFileTab).toHaveAttribute("data-selected", "")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
|
||||
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
|
||||
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
|
||||
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTogglePosition"
|
||||
const sessionID = "ses_review_toggle_position"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_toggle_position",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-toggle-position",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-toggle-position",
|
||||
projectID: "proj_review_toggle_position",
|
||||
directory,
|
||||
title: "Review toggle position",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
for (const width of [1000, 1440]) {
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const header = page.locator("[data-session-title]")
|
||||
const panel = page.locator("#review-panel")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
const closed = await toggle.boundingBox()
|
||||
if (!closed) throw new Error("Review toggle bounds are unavailable")
|
||||
const headerBox = await header.boundingBox()
|
||||
if (!headerBox) throw new Error("Session header bounds are unavailable")
|
||||
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
|
||||
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(toggle).toHaveCount(1)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.boundingBox()
|
||||
if (!box) return false
|
||||
return (
|
||||
closed.x >= box.x &&
|
||||
closed.x + closed.width <= box.x + box.width &&
|
||||
closed.y >= box.y &&
|
||||
closed.y + closed.height <= box.y + 52
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
return box ? box.y + box.height / 2 : undefined
|
||||
})
|
||||
.toBe(closed.y + closed.height / 2)
|
||||
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(toggle).toHaveCount(1)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
})
|
||||
|
||||
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const ptys: { id: string; title: string }[] = []
|
||||
const removed: string[] = []
|
||||
await page.route("**/api/pty**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
const location = { directory, project: { id: "proj_review_toggle_position", directory } }
|
||||
if (route.request().method() === "DELETE") {
|
||||
removed.push(path.split("/").at(-1)!)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (path.endsWith("/connect-token")) {
|
||||
return route.fulfill({ json: { location, data: { ticket: "e2e-ticket", expires_in: 60 } } })
|
||||
}
|
||||
if (path === "/api/pty" && route.request().method() === "POST") {
|
||||
const pty = { id: `pty_review_${ptys.length + 1}`, title: `Terminal ${ptys.length + 1}` }
|
||||
ptys.push(pty)
|
||||
return route.fulfill({ json: { location, data: pty } })
|
||||
}
|
||||
return route.fulfill({ json: { location, data: ptys.find((pty) => path.endsWith(pty.id)) ?? ptys } })
|
||||
})
|
||||
await page.routeWebSocket(/\/api\/pty\/pty_review_\d+\/connect/, () => undefined)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true })
|
||||
await expect(terminal.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
for (const number of [2, 3, 4]) {
|
||||
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tabs = await terminal.getByRole("tablist").boundingBox()
|
||||
const button = await toggle.boundingBox()
|
||||
if (!tabs || !button) return false
|
||||
return direction === "rtl" ? tabs.x >= button.x + button.width : tabs.x + tabs.width <= button.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
const fourth = terminal.locator('[data-slot="tabs-trigger-wrapper"][data-value="pty_review_4"]')
|
||||
await fourth.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab")).toHaveText(["Terminal 1", "Terminal 2", "Terminal 3"])
|
||||
expect(removed).toEqual(["pty_review_4"])
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab", { name: "Terminal 5", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
const position = await toggle.boundingBox()
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "false")
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const actions = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
const button = await toggle.boundingBox()
|
||||
if (!actions || !button) return undefined
|
||||
return actions.y + actions.height / 2 - (button.y + button.height / 2)
|
||||
})
|
||||
.toBe(0)
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const centers = await Promise.all(
|
||||
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
|
||||
button.locator("svg").evaluate((element) => {
|
||||
const svg = element as SVGSVGElement
|
||||
const path = svg.getBBox()
|
||||
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
|
||||
.y
|
||||
}),
|
||||
),
|
||||
)
|
||||
return centers[0]! - centers[1]!
|
||||
})
|
||||
.toBeCloseTo(0, 1)
|
||||
}
|
||||
@@ -23,7 +23,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
const more = header.getByRole("button", { name: "More options", exact: true })
|
||||
const review = header.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const review = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const details = header.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
@@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
|
||||
)
|
||||
const [title, menu, sidebar, summary] = boxes
|
||||
if (!title || !menu || !sidebar || !summary) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x + title.width - menu.x) <= 1 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
if (editing) {
|
||||
await header.getByRole("heading").click()
|
||||
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
|
||||
await expect(header.getByRole("textbox")).toBeFocused()
|
||||
}
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
|
||||
control.boundingBox(),
|
||||
),
|
||||
)
|
||||
const [icon, title, menu, sidebar, summary] = boxes
|
||||
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
|
||||
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
|
||||
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
|
||||
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await header.getByRole("textbox").press("Escape")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await more.click()
|
||||
const options = page.getByRole("menu")
|
||||
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
|
||||
if (direction === "ltr") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [button, menu] = await Promise.all([
|
||||
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
|
||||
options.boundingBox(),
|
||||
])
|
||||
return button && menu ? Math.abs(button.x - menu.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const rtl = getComputedStyle(element).direction === "rtl"
|
||||
return Math.min(
|
||||
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(label)
|
||||
const text = range.getBoundingClientRect()
|
||||
return rtl ? text.left - menu.left : menu.right - text.right
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(32, 0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
|
||||
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
|
||||
return (
|
||||
!!divider &&
|
||||
Math.abs(divider.left - menu.left) <= 0.5 &&
|
||||
Math.abs(divider.right - menu.right) <= 0.5 &&
|
||||
rows.every(
|
||||
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { dict } from "../../src/runtime/i18n/ar"
|
||||
import en from "../../src/runtime/i18n/en"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
for (const workspace of [false, true]) {
|
||||
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
|
||||
const copy = direction === "rtl" ? dict : en
|
||||
const directory = workspace
|
||||
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
|
||||
: fixture.directory
|
||||
const project = {
|
||||
...fixture.project,
|
||||
name: workspace
|
||||
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
|
||||
: "Timeline project",
|
||||
sandboxes: workspace ? [directory] : [],
|
||||
icon: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
|
||||
},
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript((direction) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:language",
|
||||
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
|
||||
)
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
|
||||
)
|
||||
}, direction)
|
||||
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
|
||||
const trigger = header.getByRole("button", { name: project.name, exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(trigger.locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
|
||||
)
|
||||
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await trigger.hover()
|
||||
await expect(trigger).not.toHaveCSS("background-color", background)
|
||||
await expect(page.getByRole("tooltip")).toHaveText(project.name)
|
||||
await trigger.click()
|
||||
|
||||
const menu = page.getByRole("menu", { name: project.name, exact: true })
|
||||
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
|
||||
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
|
||||
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
|
||||
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
|
||||
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
|
||||
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
|
||||
await expect(menu).toHaveCSS("direction", direction)
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await expect(settings).toBeEnabled()
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeLessThanOrEqual(320)
|
||||
for (const text of [project.name, directory]) {
|
||||
const label = menu.getByText(text, { exact: true })
|
||||
await expect(label).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(label).toHaveCSS("white-space", "nowrap")
|
||||
if (workspace) {
|
||||
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
}
|
||||
}
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const icons = menu.locator(
|
||||
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
|
||||
)
|
||||
await expect(icons).toHaveCount(3)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [button, centers] = await Promise.all([
|
||||
trigger.boundingBox(),
|
||||
icons.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return box.x + box.width / 2
|
||||
}),
|
||||
),
|
||||
])
|
||||
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
if (!workspace) await page.clock.install()
|
||||
for (const text of [project.name, directory]) {
|
||||
const label = menu.getByText(text, { exact: true })
|
||||
const item = menu.getByRole("menuitem", { name: text, exact: true })
|
||||
const anchor = item.locator("..")
|
||||
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
|
||||
const content = item.locator(".session-project-link-content")
|
||||
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
|
||||
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
|
||||
await anchor.hover()
|
||||
await expect(content).toHaveCSS("mask-image", "none")
|
||||
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
|
||||
if (text === directory) {
|
||||
await expect(openIcon).toHaveCSS("opacity", "0")
|
||||
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
|
||||
await expect
|
||||
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
|
||||
if (!row || !icon) return false
|
||||
return (
|
||||
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
|
||||
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await expect(label).toHaveCSS("cursor", "default")
|
||||
await expect(anchor).toHaveCSS("cursor", "default")
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
if (workspace) {
|
||||
await expect(tooltip).toHaveText(text)
|
||||
await expect(tooltip).toHaveCSS("white-space", "normal")
|
||||
await expect
|
||||
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeLessThanOrEqual(480)
|
||||
await expect
|
||||
.poll(() =>
|
||||
tooltip
|
||||
.getByText(text, { exact: true })
|
||||
.evaluate(
|
||||
(element) =>
|
||||
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
|
||||
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
if (!workspace) {
|
||||
await page.clock.runFor(500)
|
||||
await expect(tooltip).toBeHidden()
|
||||
}
|
||||
await settings.hover()
|
||||
await expect(tooltip).toBeHidden()
|
||||
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
|
||||
await expect(content).toHaveCSS("mask-image", "none")
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
const pathItem = menu.getByRole("menuitem", { name: directory, exact: true })
|
||||
await expect(pathItem).toBeFocused()
|
||||
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
await page.keyboard.press("Space")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(pathItem).toBeFocused()
|
||||
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await page.keyboard.press("Enter")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
project.name,
|
||||
)
|
||||
await expect(menu).toBeHidden()
|
||||
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
for (const selected of [false, true]) {
|
||||
if (selected) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
}
|
||||
await trigger.click()
|
||||
await expect(projectItem).toBeEnabled()
|
||||
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await projectItem.hover()
|
||||
await expect(projectItem).not.toHaveCSS("background-color", background)
|
||||
await projectItem.click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
await expect(menu).toBeHidden()
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
|
||||
await expect(projectRow).toBeVisible()
|
||||
await expect(projectRow).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["closed", "unopened"] as const) {
|
||||
test(`session project menu restores ${state} projects before and after messages load`, async ({ page }) => {
|
||||
const directory = "C:/OpenCode/Worktrees/project-menu-recovery"
|
||||
const messages = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: { ...fixture.project, sandboxes: [directory] },
|
||||
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
beforeMessagesResponse: (input) => (input.sessionID === fixture.targetID ? messages.promise : Promise.resolve()),
|
||||
})
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
if (state === "closed") {
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto("/")
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
|
||||
await expect(projectRow).toBeEnabled()
|
||||
await projectRow.locator("..").getByRole("button", { name: "More options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
|
||||
await expect(projectRow).toHaveCount(0)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
}
|
||||
if (state === "unopened") await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const menu = page.getByRole("menu", { name: fixture.project.name, exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
for (const loaded of [false, true]) {
|
||||
if (loaded) {
|
||||
messages.resolve()
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
fixture.project.name,
|
||||
)
|
||||
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
|
||||
await expect(projectRow).toBeVisible()
|
||||
await expect(projectRow).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.route(
|
||||
(url) => url.pathname === stressSessionHref(fixture.targetID),
|
||||
async (route) => {
|
||||
const response = await route.fetch()
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: (await response.text()).replace(
|
||||
'<div id="root"',
|
||||
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
|
||||
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
|
||||
const arrow = path.locator('[data-slot="session-project-open-icon"]')
|
||||
await expect(arrow).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
|
||||
})
|
||||
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
|
||||
})),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
|
||||
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
|
||||
const binding = settings.locator('[data-keybind-id="tab.new"]')
|
||||
await binding.click()
|
||||
await expect(binding).toHaveText("Press keys")
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(binding).toHaveText("Ctrl+T")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
|
||||
await page.keyboard.press("Control+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
})
|
||||
|
||||
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
@@ -105,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
})
|
||||
|
||||
test("about opens without waiting for contributors", async ({ page }) => {
|
||||
const contributors = Promise.withResolvers<void>()
|
||||
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
|
||||
await page.route(url, async (route) => {
|
||||
await contributors.promise
|
||||
await route.fulfill({
|
||||
json: [],
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "Link",
|
||||
Link: `<${url}&page=1004>; rel="last"`,
|
||||
},
|
||||
})
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(url)
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText(/^Version /)).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
|
||||
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
|
||||
await website.focus()
|
||||
contributors.resolve()
|
||||
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
|
||||
await expect(website).toBeFocused()
|
||||
})
|
||||
|
||||
test("about is available in the mobile settings menu", async ({ page }) => {
|
||||
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "About", exact: true }).click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
|
||||
})
|
||||
|
||||
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
|
||||
const contributors = Promise.withResolvers<void>()
|
||||
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
|
||||
await page.route(url, async (route) => {
|
||||
await contributors.promise
|
||||
await route.abort("failed")
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(url)
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
|
||||
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
|
||||
contributors.resolve()
|
||||
await failed
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/tmp/settings-padding"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_settings_padding",
|
||||
canonical: directory,
|
||||
name: "Settings padding",
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeFocused()
|
||||
})
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1280, height: 720, bottom: false },
|
||||
{ width: 900, height: 600, bottom: false },
|
||||
{ width: 780, height: 600, bottom: false },
|
||||
{ width: 390, height: 844, bottom: false },
|
||||
{ width: 390, height: 844, bottom: true },
|
||||
]) {
|
||||
test.describe(`${viewport.width}px, ${viewport.bottom ? "bottom" : "top"} navigation`, () => {
|
||||
test.use({ viewport: { width: 1280, height: 720 }, contextOptions: { reducedMotion: "reduce" } })
|
||||
|
||||
test("every settings page leaves room below its final content", async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
|
||||
if (viewport.bottom) {
|
||||
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle.getByRole("switch")).toBeChecked()
|
||||
}
|
||||
let current = "Preferences"
|
||||
for (const name of [
|
||||
"Preferences",
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Shortcuts",
|
||||
"Servers",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
"Experimental",
|
||||
"About",
|
||||
]) {
|
||||
if (viewport.width >= 816) await settings.getByRole("tab", { name, exact: true }).click()
|
||||
if (viewport.width < 816) {
|
||||
await settings.getByRole("button", { name: current, exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name, exact: true }).click()
|
||||
}
|
||||
current = name
|
||||
if (name === "About") await expect(panel.getByText("Released under the MIT License")).toBeVisible()
|
||||
if (name !== "About") {
|
||||
await expect(
|
||||
panel.getByRole("heading", { name: name === "Shortcuts" ? "Keyboard shortcuts" : name, exact: true }),
|
||||
).toBeVisible()
|
||||
}
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await expect
|
||||
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
panel.evaluate((el) => {
|
||||
const body = el.querySelector(".settings-tab-body, .settings-about-content")!
|
||||
return el.getBoundingClientRect().bottom - body.lastElementChild!.getBoundingClientRect().bottom
|
||||
}),
|
||||
{ message: `${name} bottom clearance` },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(viewport.bottom ? 119.5 : 79.5)
|
||||
await expect
|
||||
.poll(() => page.getByRole("main").evaluate((el) => el.scrollWidth - el.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ contextOptions: { reducedMotion: "reduce" }, colorScheme: "dark" })
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 900, height: 600 },
|
||||
{ width: 780, height: 600 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`preferences scroll only inside the panel at ${viewport.width}x${viewport.height}`, async ({ page }) => {
|
||||
const directory = "/tmp/settings-scroll"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_settings_scroll",
|
||||
canonical: directory,
|
||||
name: "Settings scroll",
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
const main = page.getByRole("main")
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(settings).toBeFocused()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wheel over the outer gutter must not move the entire settings screen.
|
||||
await main.hover({ position: { x: 1, y: 200 } })
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await expect.poll(() => panel.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
|
||||
await expect(main).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
|
||||
|
||||
await slider.scrollIntoViewIfNeeded()
|
||||
await slider.click()
|
||||
await page.keyboard.press("Home")
|
||||
await expect(slider).toHaveValue("0")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await expect(slider).toHaveValue("1")
|
||||
await expect(slider).toBeFocused()
|
||||
|
||||
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
await expect(
|
||||
settings.getByRole("group", { name: "Set placement and details for each activity category.", exact: true }),
|
||||
).toBeVisible()
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await expect
|
||||
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(main).toHaveJSProperty("scrollTop", 0)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import pkg from "../../package.json" with { type: "json" }
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
@@ -185,7 +184,9 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabA).toContainText(sessionA.title)
|
||||
await expect(tabB).toContainText(sessionB.title)
|
||||
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
|
||||
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
|
||||
await expect(
|
||||
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
@@ -231,7 +232,163 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
test("appearance experimental settings control vertical tab details", async ({ page }) => {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
...Array.from({ length: 24 }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_scroll_${index}`,
|
||||
})),
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(settings).toHaveText("Settings")
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(settings).toBeInViewport({ ratio: 1 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(settings).toHaveCSS("margin-top", "8px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinned = await settings.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await testInfo.attach(`vertical-tabs-settings-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
|
||||
await expect(tabB).toBeInViewport({ ratio: 1 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tab = await tabB.boundingBox()
|
||||
const viewport = await scroll.boundingBox()
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
}
|
||||
|
||||
await settings.click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(settings).toHaveAttribute("aria-pressed", "true")
|
||||
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeHidden()
|
||||
await settings.focus()
|
||||
await settings.press("Enter")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "en", direction: "ltr" },
|
||||
{ locale: "en", direction: "rtl" },
|
||||
{ locale: "ar", direction: "rtl" },
|
||||
]) {
|
||||
test(`vertical shortcut hints align at the row end: ${profile.locale} ${profile.direction}`, async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, locale }) => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, locale: profile.locale },
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
|
||||
await expect(sidebar).toHaveCSS("direction", profile.direction)
|
||||
|
||||
for (const row of [
|
||||
{ action: "home", shortcut: "Ctrl+Alt+H" },
|
||||
{ action: "new-session", shortcut: "Ctrl+Shift+N" },
|
||||
]) {
|
||||
const button = sidebar.locator(`[data-action="vertical-tabs-${row.action}"]`)
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await button.hover()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
const button = element.closest("button")!
|
||||
const row = button.getBoundingClientRect()
|
||||
const hint = element.getBoundingClientRect()
|
||||
return getComputedStyle(button).direction === "rtl" ? hint.left - row.left : row.right - hint.right
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
const newSession = sidebar.locator('[data-action="vertical-tabs-new-session"]')
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
})
|
||||
}
|
||||
|
||||
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA }) => {
|
||||
@@ -249,11 +406,14 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
|
||||
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist").getByText(/^v\d+\./)).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.locator('[data-action="settings-tab-layout"]')).toHaveCount(0)
|
||||
await expect(settings.getByRole("switch", { name: "Show project names", exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
|
||||
|
||||
const layout = settings.locator('[data-action="settings-tab-layout"]')
|
||||
await expect(layout).toContainText("Horizontal")
|
||||
@@ -274,19 +434,27 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: "Experimental", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "Appearance", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(layout).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Appearance", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "Experimental", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
|
||||
await expect(layout).toContainText("Vertical")
|
||||
await expect(projectNameSwitch).toBeChecked()
|
||||
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
|
||||
|
||||
// Reload the UI-selected preference without seeding settings storage.
|
||||
await page.reload()
|
||||
@@ -310,7 +478,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,57 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "./mock-server"
|
||||
import { APP_READY_TIMEOUT } from "./waits"
|
||||
|
||||
export const paletteSession = {
|
||||
id: "ses_command_palette",
|
||||
projectID: "proj_command_palette",
|
||||
directory: "C:/OpenCode/CommandPalette",
|
||||
title: "Palette fixture session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
|
||||
export function captureConsoleWarnings(page: Page) {
|
||||
const warnings: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "warning" && message.type() !== "error") return
|
||||
// This message comes from test isolation, not application code.
|
||||
if (message.text() === "Service Worker registration blocked by Playwright") return
|
||||
warnings.push(message.text())
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
export async function openCommandPalette(page: Page, home = false) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: paletteSession.directory,
|
||||
project: {
|
||||
id: paletteSession.projectID,
|
||||
worktree: paletteSession.directory,
|
||||
vcs: "git",
|
||||
name: "command-palette",
|
||||
time: paletteSession.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [paletteSession],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
findFiles: () => [],
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
|
||||
if (home) {
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
|
||||
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
if (!home) {
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
const input = dialog.getByRole("textbox")
|
||||
await expect(input).toBeFocused()
|
||||
await expect(dialog.getByRole("option")).not.toHaveCount(0)
|
||||
return { dialog, input }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
|
||||
@@ -78,6 +78,7 @@ export type ComposerAttachmentConfig = {
|
||||
onError: (error: unknown) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
onDragCancel?: (callback: () => void) => () => void
|
||||
store?: (file: File) => Promise<{ id: string; url: string }>
|
||||
}
|
||||
|
||||
@@ -90,6 +91,9 @@ export function createComposerAttachments(
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
},
|
||||
) {
|
||||
const clearDrag = () => {
|
||||
input.setDraggingType(null)
|
||||
}
|
||||
const capture = () => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
@@ -178,7 +182,7 @@ export function createComposerAttachments(
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
event.preventDefault()
|
||||
input.setDraggingType(null)
|
||||
clearDrag()
|
||||
const plainText = event.dataTransfer?.getData("text/plain")
|
||||
if (plainText?.startsWith("file:")) {
|
||||
const path = plainText.slice("file:".length)
|
||||
@@ -191,6 +195,8 @@ export function createComposerAttachments(
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const cancel = input.onDragCancel?.(clearDrag)
|
||||
if (cancel) onCleanup(cancel)
|
||||
makeEventListener(document, "dragover", (event) => {
|
||||
if (input.isDialogActive()) return
|
||||
event.preventDefault()
|
||||
@@ -198,7 +204,10 @@ export function createComposerAttachments(
|
||||
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
|
||||
})
|
||||
makeEventListener(document, "dragleave", (event) => {
|
||||
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
|
||||
if (!input.isDialogActive() && !event.relatedTarget) clearDrag()
|
||||
})
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
if (event.key === "Escape") clearDrag()
|
||||
})
|
||||
makeEventListener(document, "drop", handleDrop)
|
||||
})
|
||||
|
||||
@@ -134,6 +134,7 @@ function ComposerStory(props: {
|
||||
},
|
||||
variant: {
|
||||
options: () => [
|
||||
{ id: "default", label: "default" },
|
||||
{ id: "balanced", label: "balanced" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
export function ComposerDropzone(props: {
|
||||
active: boolean
|
||||
input?: { image?: boolean; pdf?: boolean }
|
||||
identity?: () => unknown
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [elements, setElements] = createStore<{ dropzone?: HTMLDivElement }>({})
|
||||
const label = createMemo(() => {
|
||||
if (!props.input?.image && !props.input?.pdf) return language.t("ui.promptInput.dropFiles")
|
||||
if (!props.input.pdf) return language.t("ui.promptInput.dropFiles.image")
|
||||
if (!props.input.image) return language.t("ui.promptInput.dropFiles.pdf")
|
||||
return language.t("ui.promptInput.dropFiles.imagePdf")
|
||||
})
|
||||
const presence = createAnimatedPresence(
|
||||
() => (props.active ? label() : undefined),
|
||||
() => elements.dropzone ?? null,
|
||||
props.identity,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-slot="session-dropzone-blur"
|
||||
data-visible={props.active}
|
||||
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
|
||||
style={{
|
||||
"-webkit-mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
"-webkit-mask-composite": "source-in",
|
||||
"mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
"mask-composite": "intersect",
|
||||
}}
|
||||
/>
|
||||
<Show when={presence.present()}>
|
||||
<div
|
||||
ref={(element) => setElements("dropzone", element)}
|
||||
data-component="session-dropzone"
|
||||
data-visible={props.active}
|
||||
class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none"
|
||||
>
|
||||
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
|
||||
<div
|
||||
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
|
||||
style={{
|
||||
"-webkit-mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="session-dropzone-stripes"
|
||||
class="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
background:
|
||||
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
|
||||
"-webkit-mask-image":
|
||||
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
|
||||
"mask-image":
|
||||
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-dropzone-content"
|
||||
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
|
||||
data-visible={props.active}
|
||||
>
|
||||
<div
|
||||
data-slot="session-dropzone-upload"
|
||||
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
|
||||
</div>
|
||||
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{presence.value()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ComposerPersistedState } from "../types"
|
||||
import { createComposerEditorActions } from "./actions"
|
||||
|
||||
@@ -97,4 +98,26 @@ describe("Composer store", () => {
|
||||
|
||||
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
|
||||
})
|
||||
|
||||
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
|
||||
const prompt = createPromptStore()
|
||||
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
|
||||
prompt.addMention(
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make("show-me"),
|
||||
name: Skill.Name.make("Show Me"),
|
||||
content: "/show-me",
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
{ start: 0, end: 0 },
|
||||
)
|
||||
expect(prompt.state.prompt).toMatchObject([
|
||||
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
|
||||
{ type: "text", content: " ", start: 8, end: 9 },
|
||||
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
|
||||
])
|
||||
expect(prompt.state.cursor).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,12 +69,15 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
|
||||
clearRetry()
|
||||
},
|
||||
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
|
||||
addMention(
|
||||
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
|
||||
range?: { start: number; end: number },
|
||||
) {
|
||||
const text = store()
|
||||
.prompt.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
const end = store().cursor ?? text.length
|
||||
const start = text.slice(0, end).lastIndexOf("@")
|
||||
const end = range?.end ?? store().cursor ?? text.length
|
||||
const start = range?.start ?? text.slice(0, end).lastIndexOf("@")
|
||||
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
|
||||
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
|
||||
clearRetry()
|
||||
@@ -113,6 +116,9 @@ function insertMention(
|
||||
end: number,
|
||||
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
|
||||
): ComposerPrompt {
|
||||
if (start === 0 && end === 0) {
|
||||
return withOffsets([mention, { type: "text", content: " ", start: 0, end: 0 }, ...prompt])
|
||||
}
|
||||
let position = 0
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
|
||||
@@ -118,7 +118,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -129,12 +128,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onDragLeave={props.controller.onDragLeave}
|
||||
onDrop={props.controller.onDrop}
|
||||
>
|
||||
<Show when={state.drag === "active"}>
|
||||
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
|
||||
{i18n.t("ui.promptInput.dropFiles")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
@@ -270,6 +263,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
@@ -575,7 +569,7 @@ export function ComposerEditorAddMenu(props: {
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
@@ -603,12 +597,14 @@ function ComposerEditorConfiguredSelect(props: {
|
||||
keybind?: string[]
|
||||
control: ComposerSelectControl
|
||||
model?: boolean
|
||||
class?: string
|
||||
}) {
|
||||
const current = () => props.control.current()
|
||||
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
|
||||
return (
|
||||
<ComposerEditorSelect
|
||||
title={props.title}
|
||||
class={props.class}
|
||||
keybind={props.control.keybind?.() ?? props.keybind}
|
||||
options={props.control.options()}
|
||||
current={current()}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function createComposerEditor(input: {
|
||||
return
|
||||
}
|
||||
if (command.type === "mention.add") {
|
||||
if (command.item.mention) draft.addMention(command.item.mention)
|
||||
if (command.item.mention) draft.addMention(command.item.mention, command.range)
|
||||
return
|
||||
}
|
||||
if (command.type === "popover.filter") {
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { createComposerSubmit, withSlashSkill } from "./submit"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -42,9 +42,14 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
|
||||
const interaction = createComposerEditorState(prompt.mode.current())
|
||||
createEffect(
|
||||
on(adapter.ready, (ready) => {
|
||||
if (ready) interaction[1]("mode", prompt.mode.current())
|
||||
}),
|
||||
on(
|
||||
() => (adapter.ready() ? prompt.mode.current() : undefined),
|
||||
(mode) => {
|
||||
if (!mode) return
|
||||
// Project external draft changes without another mode write clearing restored retry metadata.
|
||||
interaction[1](mode === "shell" ? { mode, popover: { type: "closed" } } : { mode })
|
||||
},
|
||||
),
|
||||
)
|
||||
const mode = () => interaction[0].mode
|
||||
const history = createComposerHistory()
|
||||
@@ -236,21 +241,42 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const commands = createMemo<ComposerSuggestion[]>(() =>
|
||||
slashCommands().map((item) => ({
|
||||
const slashSkills = createMemo(() =>
|
||||
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
|
||||
)
|
||||
const commands = createMemo<ComposerSuggestion[]>(() => [
|
||||
...slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
kind: "command",
|
||||
kind: "command" as const,
|
||||
label: `/${item.trigger}`,
|
||||
trigger: item.trigger,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
)
|
||||
...slashSkills().map((skill) => ({
|
||||
id: `skill:${skill.id}`,
|
||||
kind: "skill" as const,
|
||||
label: `/${skill.id}`,
|
||||
trigger: skill.id,
|
||||
title: skill.name,
|
||||
description: skill.description,
|
||||
mention: {
|
||||
type: "skill" as const,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content: `/${skill.id}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
})),
|
||||
])
|
||||
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
commands: () => data.location.command.list({ directory: sdk().directory }),
|
||||
skills: slashSkills,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
@@ -345,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
onDragCancel: platform.onDragCancel,
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
@@ -376,6 +403,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
if (queue?.editing()) {
|
||||
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
|
||||
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
@@ -31,6 +32,16 @@ const selection = {
|
||||
},
|
||||
} satisfies ModelSelection
|
||||
|
||||
const slashSkill = Skill.Info.make({
|
||||
id: Skill.ID.make("show-me"),
|
||||
name: Skill.Name.make("Show Me"),
|
||||
description: "Explain visually",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
|
||||
content: "Explain visually",
|
||||
})
|
||||
|
||||
function controls(): ComposerControls {
|
||||
return {
|
||||
agents: {
|
||||
@@ -52,10 +63,14 @@ function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
skills: () => readonly Skill.Info[] | undefined = () => [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
skills,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
@@ -107,6 +122,107 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("submits a slash skill with its trailing text and attachments", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
|
||||
state.set([
|
||||
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
|
||||
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
|
||||
])
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [slashSkill],
|
||||
).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
expect(request.text).toBe("/show-me explain @src/cache.ts")
|
||||
expect(request.skills).toEqual([
|
||||
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
|
||||
])
|
||||
expect(request.files).toEqual([
|
||||
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
|
||||
])
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ text: "/show-me", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me", slash: false, expected: [] },
|
||||
{ text: "/show-me", slash: undefined, expected: [] },
|
||||
{ text: "/show-me-extra", slash: true, expected: [] },
|
||||
{ text: "Explain /show-me", slash: true, expected: [] },
|
||||
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
|
||||
const state = createMemoryComposerState({ prompt: text }).capture()
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [{ ...slashSkill, slash }],
|
||||
).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
expect(request.text).toBe(text)
|
||||
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
|
||||
})
|
||||
|
||||
test("captures slash skills before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
|
||||
let skills: readonly Skill.Info[] | undefined = [slashSkill]
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
skills = undefined
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => skills,
|
||||
).submit(new Event("submit"))
|
||||
expect((await admitted.promise).skills).toEqual([
|
||||
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
|
||||
])
|
||||
})
|
||||
|
||||
test("sends one captured value with explicit delivery after selection switches", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const calls: string[] = []
|
||||
@@ -355,8 +471,8 @@ describe("Composer submission", () => {
|
||||
expect(promoted.mode.current()).toBe("shell")
|
||||
})
|
||||
|
||||
test("reuses the message ID when an unacknowledged admission is retried", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
|
||||
test.each(["retry me", "/show-me retry me"])("restores and retries an unacknowledged admission: %s", async (text) => {
|
||||
const state = createMemoryComposerState({ prompt: text }).capture()
|
||||
const attempts: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
@@ -366,6 +482,7 @@ describe("Composer submission", () => {
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
expect(value.skills?.map((skill) => skill.id)).toEqual(text.startsWith("/") ? ["show-me"] : [])
|
||||
throw new Error("network unavailable")
|
||||
},
|
||||
})
|
||||
@@ -384,7 +501,13 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(adapter, notify)
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [slashSkill],
|
||||
)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -394,7 +517,7 @@ describe("Composer submission", () => {
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: text }])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
@@ -420,7 +543,6 @@ describe("Composer submission", () => {
|
||||
prompt: async () => undefined,
|
||||
command: async (value) => sent.resolve(value),
|
||||
})
|
||||
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
@@ -433,7 +555,7 @@ describe("Composer submission", () => {
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
|
||||
const request = await sent.promise
|
||||
|
||||
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
|
||||
@@ -442,6 +564,57 @@ describe("Composer submission", () => {
|
||||
expect(request.delivery).toBe("steer")
|
||||
})
|
||||
|
||||
test("captures commands before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
|
||||
const catalog = [{ name: "review" }]
|
||||
const sent = Promise.withResolvers<"prompt" | "command">()
|
||||
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => sent.resolve("prompt"),
|
||||
command: async (value) => {
|
||||
requests.push(value)
|
||||
sent.resolve("command")
|
||||
},
|
||||
})
|
||||
target.directory = "C:/new-worktree"
|
||||
target.data.location.command.list = () => undefined
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
// The destination catalog has not loaded, and the source composer is leaving.
|
||||
catalog.splice(0)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => catalog,
|
||||
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
|
||||
).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sessionID: target.id,
|
||||
command: "review",
|
||||
text: "https://github.com/example/repo/pull/1",
|
||||
files: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not run an empty shell command from hidden attachments", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageUser, SkillInfo } from "@opencode-ai/client/promise"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
@@ -27,6 +28,8 @@ type ComposerSubmission = {
|
||||
type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
commands: Accessor<readonly { name: string }[] | undefined>
|
||||
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
@@ -65,6 +68,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
|
||||
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
|
||||
|
||||
try {
|
||||
const started =
|
||||
@@ -78,7 +84,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
@@ -277,15 +282,35 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
|
||||
}
|
||||
|
||||
function findCommand(session: ComposerSession, text: string) {
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
|
||||
return
|
||||
if (!commands?.some((item) => item.name === command)) return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
|
||||
const first = prompt[0]
|
||||
if (first?.type !== "text") return prompt
|
||||
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
|
||||
const skill = skills?.find((item) => item.slash === true && item.id === name)
|
||||
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
|
||||
const content = `/${skill.id}`
|
||||
return [
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content,
|
||||
start: 0,
|
||||
end: content.length,
|
||||
},
|
||||
{ ...first, content: first.content.slice(content.length), start: content.length },
|
||||
...prompt.slice(1),
|
||||
]
|
||||
}
|
||||
|
||||
async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
|
||||
@@ -35,7 +35,7 @@ export type ComposerInteractionEvent =
|
||||
export type ComposerInteractionCommand =
|
||||
| { type: "draft.setText"; value: string }
|
||||
| { type: "draft.addText"; value: string }
|
||||
| { type: "mention.add"; item: ComposerSuggestion }
|
||||
| { type: "mention.add"; item: ComposerSuggestion; range?: { start: number; end: number } }
|
||||
| { type: "popover.filter"; popover: "command" | "context"; query: string }
|
||||
| { type: "suggestion.select"; id: string }
|
||||
| { type: "focus.editor" }
|
||||
@@ -178,7 +178,13 @@ function suggestionSelected(
|
||||
: replaceTrigger(current, "/", `${item.label} `),
|
||||
})
|
||||
} else {
|
||||
commands.push({ type: "mention.add", item })
|
||||
commands.push({
|
||||
type: "mention.add",
|
||||
item,
|
||||
...(item.kind === "skill" && item.label.startsWith("/")
|
||||
? { range: { start: 0, end: state.popover.type === "command-menu" ? 0 : current.length } }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
commands.push({ type: "focus.editor" })
|
||||
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
|
||||
|
||||
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
}
|
||||
const loadItems = async (text: string) => {
|
||||
const query = text.trim()
|
||||
const items = (query: string) => {
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
|
||||
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
loadItems={loadItems}
|
||||
items={items}
|
||||
sources={[sessions]}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
|
||||
@@ -319,6 +319,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-content-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-upload-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-stripes-drift {
|
||||
to {
|
||||
background-position: 33.941px 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] {
|
||||
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
|
||||
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
|
||||
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
|
||||
animation: session-dropzone-stripes-drift 3s linear infinite;
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"] {
|
||||
--session-dropzone-wash: 4%;
|
||||
--session-dropzone-stripe: 7%;
|
||||
--session-dropzone-card: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="session-dropzone"] {
|
||||
--session-dropzone-wash: 6%;
|
||||
--session-dropzone-stripe: 14%;
|
||||
--session-dropzone-card: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-dropzone"][data-visible="true"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"] {
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
|
||||
import {
|
||||
@@ -54,6 +55,10 @@ export function NewSessionView(props: {
|
||||
data-component="new-session"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<ComposerDropzone
|
||||
active={props.composer.state.drag === "active"}
|
||||
input={props.composer.model.selection.current()?.capabilities.input}
|
||||
/>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
|
||||
@@ -676,7 +676,7 @@ export const dict = {
|
||||
"session.error.incompatible.description":
|
||||
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
|
||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
||||
"session.background.moveRunning": "Move running work to background",
|
||||
"session.background.moveRunning": "Move to background",
|
||||
"session.background.inBackground": "Running {{tasks}} in background",
|
||||
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
|
||||
"session.background.running": "Running work in background",
|
||||
@@ -812,6 +812,10 @@ export const dict = {
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.channel.local": "Local",
|
||||
"titlebar.channel.dev": "Dev",
|
||||
"titlebar.channel.beta": "Beta",
|
||||
"titlebar.toggleDebugTools": "Toggle debug tools",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
@@ -908,6 +912,25 @@ export const dict = {
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.tab.experimental": "Experimental",
|
||||
"settings.experimental.description": "Try experimental features",
|
||||
"settings.tab.about": "About",
|
||||
"settings.about.version": "Version {{version}}",
|
||||
"settings.about.devVersion": "development",
|
||||
"settings.about.license": "Released under the MIT License",
|
||||
"settings.about.writtenBy": "Written by",
|
||||
"settings.about.illustratedBy": "Illustrated by",
|
||||
"settings.about.and": "and",
|
||||
"settings.about.otherContributor.one": "{{count}} other",
|
||||
"settings.about.otherContributor.other": "{{count}} others",
|
||||
"settings.about.firstPublished": "First published in Missouri, USA",
|
||||
"settings.about.firstIllustrated": "First illustrated in London, England",
|
||||
"settings.about.website": "www.opencode.ai",
|
||||
"settings.about.description": "OpenCode, the open source coding agent",
|
||||
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
|
||||
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
|
||||
"settings.about.tagline": "AI can’t build great software, without you",
|
||||
"settings.about.copyright": "© Anomaly Innovations, Inc.",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.appearance.section.experimental": "Experimental",
|
||||
@@ -935,6 +958,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.title": "Edit project",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
|
||||
@@ -59,6 +59,9 @@ type PlatformBase = {
|
||||
/** Resolve the native source path for a desktop File. */
|
||||
getPathForFile?(file: File): string
|
||||
|
||||
/** Observe native drag cancellation that does not reach the renderer event loop. */
|
||||
onDragCancel?(callback: () => void): () => void
|
||||
|
||||
/** Open a native save file dialog and write content to the selected path (desktop only) */
|
||||
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -127,6 +130,7 @@ function createServerController(
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const source = createData({
|
||||
@@ -137,6 +141,13 @@ function createServerController(
|
||||
},
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
onError(error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(error, language.t),
|
||||
})
|
||||
},
|
||||
})
|
||||
const data = createDesktopData({
|
||||
data: source,
|
||||
|
||||
@@ -321,9 +321,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}),
|
||||
tab &&
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
id: "file.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
keybind: settings.keybinds.get("tab.close") ?? "mod+w",
|
||||
when: (event) => !(event.target instanceof Element && event.target.closest('[data-component="terminal"]')),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
].filter((v) => !!v)
|
||||
|
||||
@@ -4,16 +4,14 @@ import { useComposerState } from "@/composer/persistence"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
export function createActiveComposerAdapter(input: {
|
||||
session: SessionModel
|
||||
sessionID: string
|
||||
controls: Accessor<ComposerControls>
|
||||
submitted: () => void
|
||||
setEditor: (element: HTMLDivElement) => void
|
||||
}) {
|
||||
const id = input.session.identity.params.id
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
const id = input.sessionID
|
||||
|
||||
const prompt = useComposerState()
|
||||
prompt.current()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import type { ComposerControls } from "@/composer/adapter"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { createActiveComposerAdapter } from "./adapter"
|
||||
import { createSessionQueue } from "./queue"
|
||||
import { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
export function createSessionComposerController(input: {
|
||||
sessionID: string
|
||||
controls: Accessor<ComposerControls>
|
||||
dock: Parameters<typeof createSessionComposerRegionController>[0]
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const region = createSessionComposerRegionController(input.dock)
|
||||
let editor: HTMLDivElement | undefined
|
||||
const adapter = createActiveComposerAdapter({
|
||||
sessionID: input.sessionID,
|
||||
controls: input.controls,
|
||||
submitted: region.onResponseSubmit,
|
||||
setEditor: (element) => {
|
||||
editor = element
|
||||
region.setPromptRef(element)
|
||||
},
|
||||
})
|
||||
const queue = createSessionQueue({
|
||||
sessionID: input.sessionID,
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
restoreFocus: (cursor) => {
|
||||
const target = editor
|
||||
if (!target) return
|
||||
requestAnimationFrame(() => {
|
||||
target.focus()
|
||||
setCursorPosition(target, cursor)
|
||||
})
|
||||
},
|
||||
})
|
||||
const composer = createComposerModel(adapter, { queue })
|
||||
const editable = createMemo(() => region.showComposer() && !region.child())
|
||||
// Requests hide the view without disposing its draft or queue edit.
|
||||
createEffect(on(editable, () => composer.onDragLeave()))
|
||||
|
||||
return {
|
||||
region,
|
||||
queue,
|
||||
composer,
|
||||
drop: {
|
||||
active: () => editable() && composer.state.drag === "active",
|
||||
input: () => composer.model.selection.current()?.capabilities.input,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
|
||||
@@ -4,7 +4,6 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
|
||||
@@ -30,7 +29,7 @@ export function createSessionQueue(input: {
|
||||
draft: ComposerStateTarget
|
||||
working: Accessor<boolean>
|
||||
behavior: Accessor<ComposerDelivery>
|
||||
composer: Accessor<ComposerModel | undefined>
|
||||
restoreFocus: (cursor: number) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
@@ -159,9 +158,9 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
})
|
||||
const text = queuedPromptText(item)
|
||||
input.composer()?.dispatch({ type: "mode.normal" })
|
||||
input.draft.mode.set("normal")
|
||||
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
input.composer()?.restoreFocus(text.length)
|
||||
input.restoreFocus(text.length)
|
||||
return true
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
@@ -170,10 +169,10 @@ export function createSessionQueue(input: {
|
||||
setState("editing", undefined)
|
||||
// Mode first, then prompt, then retry: mode and prompt writes both clear
|
||||
// the retry marker.
|
||||
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
|
||||
input.draft.mode.set(editing.stash.mode)
|
||||
input.draft.set(editing.stash.prompt, editing.stash.cursor)
|
||||
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
|
||||
input.composer()?.restoreFocus(editing.stash.cursor)
|
||||
input.restoreFocus(editing.stash.cursor)
|
||||
}
|
||||
const confirmEdit = (delivery: ComposerDelivery) => {
|
||||
const editing = state.editing
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
@@ -25,18 +24,16 @@ import { restorePromptModel, syncPromptModel, syncSessionModel } from "../sessio
|
||||
import type { SessionTimelineInteraction } from "../timeline/interaction"
|
||||
import { createSessionRevert } from "../revert"
|
||||
import { SessionComposerRegion } from "./session-composer-region"
|
||||
import { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||
import { createActiveComposerAdapter } from "./adapter"
|
||||
import { createSessionQueue } from "./queue"
|
||||
import { createSessionComposerController, type SessionComposerController } from "./controller"
|
||||
import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
screen: SessionScreenLayout
|
||||
timeline: SessionTimelineInteraction
|
||||
visible: Accessor<boolean>
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
@@ -180,7 +177,30 @@ export function createActiveSessionRegion(input: {
|
||||
},
|
||||
])
|
||||
|
||||
const dock = {
|
||||
state,
|
||||
parentID: input.session.data.parentID,
|
||||
centered: input.screen.centered,
|
||||
onResponseSubmit: input.timeline.actions.resume,
|
||||
openParent,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
promptRef = element
|
||||
},
|
||||
setDockRef: input.timeline.view.setDockRef,
|
||||
}
|
||||
const active = createMemo(
|
||||
on(
|
||||
() => (input.visible() ? input.session.identity.sessionID() : undefined),
|
||||
(sessionID) => (sessionID ? createSessionComposerController({ sessionID, controls, dock }) : undefined),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
active,
|
||||
drop: {
|
||||
active: () => active()?.drop.active() ?? false,
|
||||
input: () => active()?.drop.input(),
|
||||
},
|
||||
actions: {
|
||||
timeline: {
|
||||
get revert() {
|
||||
@@ -190,76 +210,25 @@ export function createActiveSessionRegion(input: {
|
||||
openAttachment,
|
||||
} satisfies SessionUserActions,
|
||||
},
|
||||
region: {
|
||||
centered: input.screen.centered,
|
||||
openParent,
|
||||
prompt,
|
||||
setDockRef: input.timeline.view.setDockRef,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
promptRef = element
|
||||
},
|
||||
state,
|
||||
},
|
||||
input: {
|
||||
controls,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
promptRef = element
|
||||
},
|
||||
},
|
||||
submitted: () => input.timeline.actions.resume(),
|
||||
requests: state,
|
||||
workspaceMoveEligible: () => true,
|
||||
}
|
||||
}
|
||||
|
||||
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
|
||||
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
parentID: props.session.data.parentID,
|
||||
centered: props.model.region.centered,
|
||||
onResponseSubmit: props.onResponseSubmit,
|
||||
openParent: props.model.region.openParent,
|
||||
setPromptRef: props.model.region.setPromptRef,
|
||||
setDockRef: props.model.region.setDockRef,
|
||||
})
|
||||
const adapter = createActiveComposerAdapter({
|
||||
session: props.session,
|
||||
controls: props.model.input.controls,
|
||||
submitted: props.model.submitted,
|
||||
setEditor: props.model.input.setPromptRef,
|
||||
})
|
||||
let composer: ComposerModel | undefined
|
||||
const queue = createSessionQueue({
|
||||
sessionID: requireSessionID(props.session),
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
composer: () => composer,
|
||||
})
|
||||
composer = createComposerModel(adapter, { queue })
|
||||
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
controller={props.model.region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
<Composer model={props.model.composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function requireSessionID(session: SessionModel) {
|
||||
const id = session.identity.params.id
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
|
||||
when={!props.placeholder}
|
||||
fallback={
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" />
|
||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export function SessionSidePanel(props: {
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
@@ -417,7 +417,7 @@ export function SessionSidePanel(props: {
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" />}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
@@ -427,11 +427,16 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
|
||||
data-slot="session-side-panel-actions"
|
||||
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
|
||||
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppButton directory={projectDirectory} />
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SortableTab(props: {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
|
||||
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
|
||||
export function SessionReviewToggle() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
return (
|
||||
<SessionHeaderActions
|
||||
state={{
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: true,
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionHeaderActionsState = {
|
||||
reviewLabel: string
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarRight>
|
||||
@@ -35,7 +23,9 @@ export function SessionHeader() {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
<Show when={isDesktop() && !view().reviewPanel.opened()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
@@ -28,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
@@ -133,8 +135,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
session,
|
||||
screen,
|
||||
timeline,
|
||||
visible: conversationVisible,
|
||||
})
|
||||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
@@ -175,7 +177,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
review.mobile.setTab("changes")
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
backgroundTasks={composer.region.state.background.tasks()}
|
||||
backgroundTasks={composer.requests.background.tasks()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -197,6 +199,11 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<ComposerDropzone
|
||||
active={composer.drop.active()}
|
||||
input={composer.drop.input()}
|
||||
identity={session.layout.tabKey}
|
||||
/>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
@@ -234,7 +241,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<MessageTimeline
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.region.state.background}
|
||||
background={composer.requests.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
@@ -262,10 +269,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => <ActiveSessionComposerRegion model={model} />}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
@@ -274,10 +279,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
|
||||
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
|
||||
<div
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
|
||||
true,
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
@@ -408,6 +422,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
present={store.sideTerminalPresent}
|
||||
animate={sidePresence.animate() || sideMotion().animateTerminal}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
reserveReviewToggle={!screen.side.region.open()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
|
||||
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": props.raised,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
|
||||
[data-popper-positioner]:has(.session-project-info-tooltip),
|
||||
.session-project-info-tooltip {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.session-options-menu [data-component="menu-v2-item"] {
|
||||
padding-inline-end: 30px;
|
||||
}
|
||||
|
||||
.session-project-link {
|
||||
--session-project-fade-direction: to right;
|
||||
position: relative;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-project-fade-direction: to left;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-project-link-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
inset-block: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
|
||||
.session-project-link-content {
|
||||
mask-image: linear-gradient(
|
||||
var(--session-project-fade-direction),
|
||||
#000 calc(100% - 40px),
|
||||
transparent calc(100% - 24px)
|
||||
);
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
@@ -25,6 +34,173 @@ export function SessionTitleHeader(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionProjectMenu(props: {
|
||||
project?: Omit<LocalProject, "expanded">
|
||||
directory?: string
|
||||
workspace: boolean
|
||||
showProjectIcon: boolean
|
||||
}) {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
projectTruncated: false,
|
||||
pathTruncated: false,
|
||||
pathFocused: false,
|
||||
})
|
||||
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
|
||||
const canOpenPath = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
|
||||
const openPath = () => {
|
||||
if (!canOpenPath() || !platform.openPath || !props.directory) return
|
||||
void platform.openPath(props.directory).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const openProjectSettings = async () => {
|
||||
const current = props.project
|
||||
if (!current) return
|
||||
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
|
||||
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
shift={-10}
|
||||
modal={false}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState({ open, pathFocused: false })}
|
||||
>
|
||||
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
aria-label={projectName()}
|
||||
data-slot="session-project-trigger"
|
||||
icon={
|
||||
<Show
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.projectTruncated}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi>{projectName()}</bdi>}
|
||||
>
|
||||
<Menu.Item
|
||||
class="min-w-0 w-full"
|
||||
disabled={!props.project}
|
||||
onSelect={() => {
|
||||
const project = props.project
|
||||
if (!project) return
|
||||
server.ctx.projects.open(project.worktree)
|
||||
layout.home.setSelection({ server: server.key, directory: project.worktree })
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("projectTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
class="min-w-0 truncate text-13-medium"
|
||||
>
|
||||
{projectName()}
|
||||
</bdi>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.pathTruncated}
|
||||
forceOpen={state.pathFocused && state.pathTruncated ? true : undefined}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi dir="ltr">{props.directory}</bdi>}
|
||||
>
|
||||
{/* Read-only paths stay in keyboard navigation so their full tooltip remains accessible. */}
|
||||
<Menu.Item
|
||||
class="session-project-link min-w-0 w-full cursor-default"
|
||||
disabled={!props.directory}
|
||||
aria-disabled={!canOpenPath()}
|
||||
closeOnSelect={canOpenPath()}
|
||||
onSelect={openPath}
|
||||
onFocus={() => setState("pathFocused", true)}
|
||||
onBlur={() => setState("pathFocused", false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setState({ open: false, pathFocused: false })
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("pathTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
dir="ltr"
|
||||
class="min-w-0 truncate text-v2-text-text-muted"
|
||||
>
|
||||
{props.directory}
|
||||
</bdi>
|
||||
</span>
|
||||
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
|
||||
<Icon name="arrow-up-right" />
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
|
||||
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
|
||||
{language.t("project.settings.title")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -66,17 +242,22 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
)
|
||||
const title = createMemo(() =>
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
? language.t("session.tab.session")
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
return projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
return (
|
||||
projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
|
||||
)
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
@@ -95,25 +276,13 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center">
|
||||
<span
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
|
||||
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={showProjectIcon()}
|
||||
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
|
||||
src={getProjectAvatarSource(project()?.id, project()?.icon)}
|
||||
variant={getProjectAvatarVariant(project()?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
|
||||
<SessionProjectMenu
|
||||
project={project()}
|
||||
directory={directory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentTitle()}>
|
||||
{(value) => (
|
||||
<button
|
||||
@@ -141,7 +310,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
|
||||
@@ -45,6 +45,7 @@ export function TerminalPanel(
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
animate?: boolean
|
||||
reserveReviewToggle?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const terminal = useTerminal()
|
||||
@@ -249,7 +250,10 @@ export function TerminalPanel(
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
<div
|
||||
class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden"
|
||||
classList={{ "pe-12": props.reserveReviewToggle }}
|
||||
>
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
@@ -291,46 +295,53 @@ export function TerminalPanel(
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-[52px] !flex-none"
|
||||
>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-full min-w-0 !flex-1"
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
class="!border-b-0"
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{/* Reserve outside the scroll viewport so overflowing tabs cannot cover the toggle. */}
|
||||
<Show when={props.reserveReviewToggle}>
|
||||
<div class="w-12 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -31,7 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
|
||||
type BackgroundTask = {
|
||||
@@ -59,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
icon="outline-arrow-to-corner-top-right"
|
||||
class="max-w-full"
|
||||
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
|
||||
onClick={() => props.onMove?.()}
|
||||
@@ -409,10 +407,9 @@ function MessageTimelineView(
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
|
||||
const avatarProject = createMemo(() => {
|
||||
if (!showProjectIcon()) return
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list())
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
})
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
@@ -666,36 +663,13 @@ function MessageTimelineView(
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -719,7 +693,7 @@ function MessageTimelineView(
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
@@ -734,7 +708,7 @@ function MessageTimelineView(
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
@@ -761,7 +735,7 @@ function MessageTimelineView(
|
||||
{(id) => (
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
placement="bottom-start"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
@@ -776,7 +750,8 @@ function MessageTimelineView(
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ "min-width": "160px" }}
|
||||
class="session-options-menu w-max"
|
||||
style={{ "min-width": "0" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user