mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b683551b11 | |||
| 401a154ec3 | |||
| 18c25ae406 | |||
| cd156f9f1c | |||
| b968a314d3 | |||
| 93cdfafa78 | |||
| b6a8d99da5 | |||
| 8cacf150db | |||
| 76e7b556b6 | |||
| a888ba4da7 | |||
| a1eca087d4 | |||
| 5252cefab2 | |||
| 839343e5de | |||
| 30d778cf5f | |||
| 616aba7bbd | |||
| 006a0a4b34 | |||
| 3facbe1dd3 | |||
| 1430a460f1 | |||
| 1f96af5ea7 | |||
| 15f0fc9e00 | |||
| cd3133038b | |||
| e55a91eadd | |||
| 2f8fa6afef | |||
| 5e11cf5fc9 | |||
| f5917726d3 | |||
| eb22768a23 | |||
| 8644db2c2a | |||
| aca42423d3 | |||
| d9b81d2233 | |||
| 8fc65b3038 | |||
| f161c90057 | |||
| 728ae7c949 | |||
| 42fc297d30 | |||
| 43dd33842e | |||
| 3d13b6c5b6 | |||
| 996b05432f | |||
| c53f4cfb09 | |||
| 45a49ae32a | |||
| 238ce304a9 | |||
| cf606660fb | |||
| 759695d87c | |||
| f14724dfb1 |
@@ -1,52 +0,0 @@
|
||||
name: generate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Generate
|
||||
run: ./script/generate.ts
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git add -A
|
||||
git commit -m "chore: generate" --allow-empty
|
||||
git push origin HEAD:${{ github.ref_name }} --no-verify
|
||||
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
|
||||
# echo ""
|
||||
# echo "============================================"
|
||||
# echo "Failed to push generated code."
|
||||
# echo "Please run locally and push:"
|
||||
# echo ""
|
||||
# echo " ./script/generate.ts"
|
||||
# echo " git add -A && git commit -m \"chore: generate\" && git push"
|
||||
# echo ""
|
||||
# echo "============================================"
|
||||
# exit 1
|
||||
# fi
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -594,6 +594,7 @@ jobs:
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.build-node-cli.result == 'success'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -80,6 +80,8 @@ jobs:
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||
run: |
|
||||
bun run script/build.ts --single --skip-install
|
||||
bun run script/service-smoke.ts
|
||||
@@ -94,6 +96,8 @@ jobs:
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||
run: |
|
||||
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||
bun run script/service-smoke.ts --node
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: opencode-dev
|
||||
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
|
||||
---
|
||||
|
||||
# Debugging opencode itself
|
||||
|
||||
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
|
||||
|
||||
## Server/client model
|
||||
|
||||
opencode V2 is a client/server system, not a single monolithic process:
|
||||
|
||||
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
|
||||
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
|
||||
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
|
||||
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
|
||||
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
|
||||
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
|
||||
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
|
||||
|
||||
## Starting the dev TUI
|
||||
|
||||
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
|
||||
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
|
||||
|
||||
## Interactive debugging with termctrl
|
||||
|
||||
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
|
||||
- Use a dedicated session name and do not reuse or kill an unrelated session.
|
||||
|
||||
```bash
|
||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
|
||||
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
|
||||
termctrl show opencode-v2-dev
|
||||
```
|
||||
|
||||
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
|
||||
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
|
||||
|
||||
```bash
|
||||
termctrl send opencode-v2-dev 'text:example prompt' enter
|
||||
termctrl send opencode-v2-dev ctrl-c
|
||||
```
|
||||
|
||||
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
|
||||
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
|
||||
|
||||
```bash
|
||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
|
||||
```
|
||||
|
||||
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
|
||||
|
||||
```bash
|
||||
termctrl resize opencode-v2-dev --cols 100 --rows 30
|
||||
termctrl show opencode-v2-dev
|
||||
```
|
||||
|
||||
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
|
||||
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
|
||||
- Always clean up the Terminal Control session when the check is complete:
|
||||
|
||||
```bash
|
||||
termctrl stop opencode-v2-dev
|
||||
```
|
||||
|
||||
## Comparing V2 against the legacy TUI
|
||||
|
||||
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
|
||||
|
||||
```bash
|
||||
# From packages/cli: local V2 TUI
|
||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
|
||||
|
||||
# Released legacy TUI behavior reference
|
||||
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
|
||||
|
||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
|
||||
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
|
||||
```
|
||||
|
||||
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
|
||||
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
|
||||
|
||||
## Server/API debugging
|
||||
|
||||
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
|
||||
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
|
||||
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
|
||||
|
||||
```bash
|
||||
bun dev api get /health
|
||||
bun dev api get /openapi.json
|
||||
bun dev api <operationId> --param key=value
|
||||
```
|
||||
|
||||
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## Auditing installed `opencode2` sessions
|
||||
|
||||
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
|
||||
|
||||
For a supplied `ses_...` ID, compare three sources:
|
||||
|
||||
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
|
||||
- The database's ordered `event` rows for durable history.
|
||||
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
|
||||
|
||||
Locate an uncertain database without modifying it:
|
||||
|
||||
```bash
|
||||
SESSION=ses_...
|
||||
for db in ~/.local/share/opencode/*.db; do
|
||||
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
|
||||
done
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
|
||||
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
|
||||
- Tail the live file while reproducing an issue instead of guessing from stale output:
|
||||
|
||||
```bash
|
||||
tail -f ~/.local/share/opencode/log/opencode-local.log
|
||||
```
|
||||
|
||||
- Filter to one run or role when the file is noisy:
|
||||
|
||||
```bash
|
||||
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
|
||||
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
|
||||
```
|
||||
|
||||
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
|
||||
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
|
||||
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
|
||||
|
||||
## Heap snapshots
|
||||
|
||||
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
|
||||
|
||||
1. Find the processes and inspect their roles and memory:
|
||||
|
||||
```bash
|
||||
pgrep -a -f 'opencode2\.exe|opencode2'
|
||||
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
|
||||
```
|
||||
|
||||
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
|
||||
|
||||
```bash
|
||||
kill -USR1 <server-pid>
|
||||
```
|
||||
|
||||
3. Wait for `heap snapshot written` in the channel's log before opening the file. Snapshots are written to the same log directory as `heap-<pid>-<timestamp>.heapsnapshot`; writing a large heap can take several seconds and the file is incomplete until the completion message appears:
|
||||
|
||||
```bash
|
||||
grep 'heap snapshot' ~/.local/share/opencode/log/opencode.log | tail
|
||||
find ~/.local/share/opencode/log -maxdepth 1 -name 'heap-<server-pid>-*.heapsnapshot' -printf '%T@ %s %p\n' | sort -nr | head
|
||||
```
|
||||
|
||||
Use `opencode-local.log` instead for a local/dev channel process. The log's `path=` field is authoritative.
|
||||
|
||||
4. Analyze the snapshot with Chrome DevTools, a V8 heap snapshot parser, or a temporary tool installed outside the repository. Start with the largest retained objects, dominators, object counts grouped by constructor/name, and retainer paths back to GC roots. Relate suspicious names and paths back to the source tree rather than treating large shallow allocations as leaks.
|
||||
|
||||
For command-line analysis, install tooling under `/tmp/opencode`, not in the repository. For example, MemLab can rank dominators and trace a reported heap object ID back to a GC root:
|
||||
|
||||
```bash
|
||||
npm install --prefix /tmp/opencode/heap-analysis @memlab/cli
|
||||
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze object-size --snapshot <snapshot>
|
||||
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze shape --snapshot <snapshot>
|
||||
/tmp/opencode/heap-analysis/node_modules/.bin/memlab trace --snapshot <snapshot> --node-id=<id>
|
||||
```
|
||||
|
||||
A single snapshot explains what retains memory at one point in time, but does not by itself prove a leak. For leak confirmation, capture a baseline, perform a controlled repeated workload, allow idle cleanup/GC when possible, capture another snapshot, and compare growth and retainer paths. Also compare snapshot heap size with process RSS: a large difference can indicate native allocations, database mappings, allocator fragmentation, or other memory outside the JavaScript heap.
|
||||
|
||||
```bash
|
||||
cat /proc/<pid>/smaps_rollup
|
||||
pmap -x <pid> | sort -k3 -nr | head -25
|
||||
```
|
||||
|
||||
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
|
||||
|
||||
## Debugger
|
||||
|
||||
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
|
||||
|
||||
```bash
|
||||
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
|
||||
bun run --inspect=ws://localhost:6499/ src/index.ts
|
||||
```
|
||||
|
||||
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until a debugger attaches.
|
||||
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
|
||||
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
|
||||
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
|
||||
@@ -191,7 +191,7 @@
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -520,7 +520,7 @@
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-beta.101",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -700,6 +700,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
@@ -916,7 +917,7 @@
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"katex": "0.16.47",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
@@ -1053,9 +1054,9 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch",
|
||||
@@ -1067,6 +1068,7 @@
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
@@ -1077,9 +1079,10 @@
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||
"@effect/platform-node": "4.0.0-beta.107",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -1116,7 +1119,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1563,13 +1566,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.101", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.101" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IdejlqRLbjRHJgVnea4s8CxTWfvkSjM0HlnpNfP07IGTbhmAvPs7PMaWt1xzYWbOnI7CriQwxv+54eW1PwIkZg=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.107", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-beta.107" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-WxR3OEcwVtckNYGxvERA4kiS8cb2B46lSWxQw8P6dCCzW0j0VC7hkWyzryJ16MVXfI/5xQHS3r5j9mud+JVvsg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.107", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.107", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107", "ioredis": ">=5.7.0 <6.0.0" } }, "sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.107", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-s6AC7LXCEjCN+nKegKFY4MOi6bmT1+SLR9YHEYwhY3P5qyQQB4R5yYLgt+3J4EPp5fa3t4FIDdPEUwz9LdKm6g=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.107", "", { "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-BuSSCUoXz6JSR30wT4Y0ukmJKvIFhm/gROwEeA0nLO5QYf33CdTunwX+q35+/MNOOLT8jL3xaDsz5R5aAF1vYQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -3871,7 +3874,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
|
||||
"effect": ["effect@4.0.0-beta.107", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4073,8 +4076,6 @@
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
|
||||
@@ -4325,7 +4326,7 @@
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
@@ -4523,7 +4524,7 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
|
||||
|
||||
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
|
||||
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
@@ -4833,8 +4834,6 @@
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
|
||||
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
|
||||
@@ -5641,8 +5640,6 @@
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
||||
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
||||
|
||||
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
||||
@@ -6247,6 +6244,8 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -6301,10 +6300,6 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="],
|
||||
|
||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="],
|
||||
@@ -6793,8 +6788,6 @@
|
||||
|
||||
"mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
|
||||
|
||||
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
@@ -7817,8 +7810,6 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
+7
-5
@@ -37,9 +37,10 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||
"@effect/platform-node": "4.0.0-beta.107",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -70,7 +71,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -154,6 +155,7 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
@@ -164,6 +166,7 @@
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
@@ -171,7 +174,6 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
@@ -166,6 +166,7 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
const GeminiBodyFields = {
|
||||
cachedContent: Schema.optional(Schema.String),
|
||||
contents: Schema.Array(GeminiContent),
|
||||
labels: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetySettings: optionalArray(GeminiSafetySetting),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||
@@ -190,8 +191,19 @@ const GeminiCandidate = Schema.Struct({
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
@@ -200,6 +212,7 @@ interface ParserState {
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly nextToolCallId: number
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
@@ -503,32 +516,37 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
raw: state.finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
|
||||
raw: finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
@@ -569,7 +587,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
|
||||
@@ -202,14 +202,15 @@ export const errorText = (error: unknown) => {
|
||||
* decoder, and drops empty / `[DONE]` keep-alive events so the downstream
|
||||
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
|
||||
* `Retry` control event on its error channel; we drop it here (we don't
|
||||
* implement client-driven retries) so the public error channel stays
|
||||
* `AIError`.
|
||||
* implement client-driven retries). Decoder failures become provider output
|
||||
* errors so the public error channel stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
||||
bytes.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.pipeThroughChannel(Sse.decode()),
|
||||
Stream.catchTag("Retry", () => Stream.empty),
|
||||
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Effect } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID, type ProviderOptions } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
readonly labels?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type GeminiProviderOptionsInput = ProviderOptions & {
|
||||
readonly gemini?: GeminiOptionsInput
|
||||
}
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -17,7 +24,7 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
readonly providerOptions?: GeminiProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
@@ -28,14 +35,33 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
readonly providerOptions?: GeminiProviderOptionsInput
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* Gemini.protocol.body.from(request)
|
||||
const value = request.providerOptions?.gemini?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
...Gemini.protocol,
|
||||
body: {
|
||||
...Gemini.protocol.body,
|
||||
from: fromRequest,
|
||||
},
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: id,
|
||||
providerMetadataKey: "google",
|
||||
protocol: Gemini.protocol,
|
||||
protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
||||
@@ -78,7 +104,7 @@ export const configure = (input: Config = {}) => {
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||
configuredRoute(input, modelID).model<GeminiProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -87,10 +113,7 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
|
||||
@@ -148,7 +148,7 @@ export const AIErrorReason = Schema.Union([
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
||||
|
||||
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
|
||||
export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
|
||||
@@ -75,7 +75,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
||||
|
||||
@@ -702,6 +702,80 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves unsigned parallel calls unchanged after a signed Gemini 3 call", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { thoughtSignature: "parallel_signature" } },
|
||||
}),
|
||||
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
|
||||
ToolCallPart.make({ id: "tool_2", name: "lookup", input: { query: "sports" } }),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds the validator bypass sentinel to every call in an unsigned Gemini 3 batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } }),
|
||||
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
@@ -767,6 +841,31 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults omitted function call args to an empty object", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "ping", description: "Ping", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ functionCall: { name: "ping" } }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps tool calls without a finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -862,6 +961,51 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves candidate-less prompt safety blocks as content-filter outcomes", () =>
|
||||
Effect.gen(function* () {
|
||||
const blocked = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
promptFeedback: {
|
||||
blockReason: "FUTURE_SAFETY_REASON",
|
||||
blockReasonMessage: "Prompt blocked",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const blockedWithUsage = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ promptFeedback: { blockReason: "SAFETY" } },
|
||||
{ usageMetadata: { promptTokenCount: 7, totalTokenCount: 7 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(blocked.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(blocked.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "content-filter", raw: "FUTURE_SAFETY_REASON" },
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptFeedback: {
|
||||
blockReason: "FUTURE_SAFETY_REASON",
|
||||
blockReasonMessage: "Prompt blocked",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(blockedWithUsage.finishReason).toEqual({ normalized: "content-filter", raw: "SAFETY" })
|
||||
expect(blockedWithUsage.usage).toMatchObject({ inputTokens: 7, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
|
||||
@@ -54,6 +54,27 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds billing labels to Vertex Gemini requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
gemini: { labels: { component: "opencode", environment: "test" } },
|
||||
},
|
||||
}).model("gemini-3.5-flash"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
labels: { component: "opencode", environment: "test" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
||||
import {
|
||||
@@ -90,6 +90,18 @@ describe("AI.Usage", () => {
|
||||
expect(ProviderShared.sumTokens()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sseFraming maps decoder failures to AI errors", async () => {
|
||||
const error = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flip,
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
})
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -97,13 +98,15 @@ test.describe("timeline adverse visual stability", () => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
||||
const trigger = page.locator(
|
||||
`[data-timeline-part-id="${renderedPartID(targetID)}"] [data-slot="collapsible-trigger"]`,
|
||||
)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(targetID)}"]`)).toHaveCount(0)
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -127,13 +130,16 @@ test.describe("timeline adverse visual stability", () => {
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -184,10 +190,13 @@ test.describe("timeline adverse visual stability", () => {
|
||||
})
|
||||
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
event,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
grep: { path: ".", pattern: "stable", include: "*.ts" },
|
||||
list: { path: "src" },
|
||||
}
|
||||
|
||||
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_append_01_read"
|
||||
const followingID = "prt_append_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
|
||||
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
|
||||
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-append",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
|
||||
const textID = "prt_split_02_text"
|
||||
const followingID = "prt_split_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart("prt_split_01_read", "read", "completed", inputs.read),
|
||||
textPart(textID, "Boundary"),
|
||||
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
|
||||
textPart(followingID, "Following split groups"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-split-merge",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["following"] },
|
||||
{ type: "unique", regions: ["following"] },
|
||||
{ type: "stable", regions: ["following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("removing the first context member replaces the group once without overlapping following content", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
|
||||
const followingID = "prt_key_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", inputs.read),
|
||||
toolPart(ids[1]!, "glob", "completed", inputs.glob),
|
||||
toolPart(ids[2]!, "grep", "completed", inputs.grep),
|
||||
textPart(followingID, "Following replaced group"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const originalRowKey = await original.evaluate((element) =>
|
||||
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
|
||||
)
|
||||
await original.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-first-remove",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
|
||||
expect(
|
||||
await page
|
||||
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
|
||||
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
|
||||
).toBe(originalRowKey)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -34,13 +35,16 @@ for (const deviceScaleFactor of [1, 1.25]) {
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -71,13 +75,16 @@ for (const reducedMotion of [true]) {
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
@@ -43,11 +44,17 @@ for (const profile of profiles) {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
tool: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
@@ -35,12 +36,23 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
const trigger = page
|
||||
.locator(`[data-timeline-part-id="${renderedPartID(patchID)}"] [data-slot="collapsible-trigger"]`)
|
||||
.first()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(patchID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
patch: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(patchID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
const second = patchFile("src/b.ts", "add")
|
||||
|
||||
@@ -17,19 +17,28 @@ describe("timeline fixture validation", () => {
|
||||
test("rejects malformed SDK values at runtime", () => {
|
||||
expect(() =>
|
||||
assistantMessage([], {
|
||||
error: { name: "APIError", data: { message: "failed" } } as never,
|
||||
error: { type: "APIError", message: 1 } as never,
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineEvent({
|
||||
directory: "C:/OpenCode/TimelineStability",
|
||||
payload: {
|
||||
id: "evt_invalid_status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
},
|
||||
id: "evt_invalid_status",
|
||||
created: 1,
|
||||
type: "session.status",
|
||||
data: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() => validateTimelineMessages([{ ...userMessage(), id: "invalid" } as never])).toThrow()
|
||||
expect(() => validateTimelineMessages([{ ...userMessage(), time: { created: "invalid" } } as never])).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineMessages([
|
||||
userMessage(),
|
||||
{
|
||||
...assistantMessage(),
|
||||
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
|
||||
} as never,
|
||||
]),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects duplicate IDs and orphan assistants", () => {
|
||||
@@ -42,8 +51,8 @@ describe("timeline fixture validation", () => {
|
||||
test("assigns deterministic event IDs", () => {
|
||||
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
||||
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
||||
expect(first.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.id.slice(-4))).toBe(Number(first.id.slice(-4)) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
SessionStatus,
|
||||
SessionStructuredError,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -18,50 +25,80 @@ export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const tokens = { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
type Session = SessionInfo
|
||||
type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: {
|
||||
id: string
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
type TextSeed = {
|
||||
id: string
|
||||
type: "text"
|
||||
text: string
|
||||
messageID?: string
|
||||
}
|
||||
type FileSeed = {
|
||||
id: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string
|
||||
url: string
|
||||
source?: { type: string; path?: string; text?: { value: string; start: number; end: number } }
|
||||
}
|
||||
type AgentSeed = {
|
||||
id: string
|
||||
type: "agent"
|
||||
name: string
|
||||
source?: { value: string; start: number; end: number }
|
||||
}
|
||||
type ReasoningSeed = {
|
||||
id: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
time?: { start: number; end?: number }
|
||||
metadata?: Record<string, unknown>
|
||||
messageID?: string
|
||||
}
|
||||
type ToolSeed = {
|
||||
id: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
messageID?: string
|
||||
executed?: boolean
|
||||
providerState?: Record<string, unknown>
|
||||
providerResultState?: Record<string, unknown>
|
||||
state:
|
||||
| { status: "pending"; input: Record<string, unknown>; raw: string }
|
||||
| {
|
||||
status: "running"
|
||||
input: Record<string, unknown>
|
||||
title?: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number }
|
||||
}
|
||||
| {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
| {
|
||||
status: "error"
|
||||
input: Record<string, unknown>
|
||||
error: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
type TimelineProperties = {
|
||||
"message.updated": { sessionID: string; info: Message }
|
||||
"message.removed": { sessionID: string; messageID: string }
|
||||
"message.part.updated": { sessionID: string; part: Part; time: number }
|
||||
"message.part.removed": { sessionID: string; messageID: string; partID: string }
|
||||
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
|
||||
"session.status": { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
type TimelinePayload = {
|
||||
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
|
||||
}[keyof TimelineProperties]
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
|
||||
export type EventPayload = TimelineEvent
|
||||
export type ToolStatus = ToolState["status"]
|
||||
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
|
||||
|
||||
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
|
||||
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
|
||||
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
|
||||
export type PartSeed<Owner extends Message["role"]> =
|
||||
OwnedPart<Owner> extends infer Candidate
|
||||
? Candidate extends Part
|
||||
? Omit<Candidate, "sessionID" | "messageID">
|
||||
: never
|
||||
: never
|
||||
export type TimelineMessage = SessionMessageUser | SessionMessageAssistant
|
||||
export type TimelineEvent = OpenCodeEvent | readonly OpenCodeEvent[]
|
||||
export type EventPayload = OpenCodeEvent
|
||||
export type ToolStatus = ToolSeed["state"]["status"]
|
||||
export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
|
||||
? TextSeed | FileSeed | AgentSeed
|
||||
: TextSeed | ReasoningSeed | ToolSeed
|
||||
|
||||
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||
@@ -71,26 +108,19 @@ type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
||||
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
||||
const timelineEventSchema = Schema.Union([
|
||||
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
|
||||
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
|
||||
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
|
||||
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
|
||||
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
|
||||
eventSchema("session.status", SessionStatusEvent.Status.data),
|
||||
])
|
||||
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
|
||||
type PartRef = { messageID: string; type: "text" | "reasoning" | "tool"; ordinal?: number }
|
||||
const partRefs = new Map<string, PartRef>()
|
||||
const nextOrdinals = new Map<string, { text: number; reasoning: number }>()
|
||||
const startedParts = new Set<string>()
|
||||
const toolStates = new Map<string, ToolStatus>()
|
||||
let eventSequence = 0
|
||||
let durableSequence = -1
|
||||
|
||||
export async function setupTimeline(
|
||||
page: Page,
|
||||
input: {
|
||||
messages?: TimelineMessage[]
|
||||
currentMessages?: SessionMessageInfo[]
|
||||
sessionMessages?: SessionMessageInfo[]
|
||||
sessionStatus?: Record<string, SessionStatus>
|
||||
settings?: Record<string, boolean>
|
||||
sessions?: Session[]
|
||||
@@ -103,40 +133,26 @@ export async function setupTimeline(
|
||||
seedHistory?: boolean
|
||||
} = {},
|
||||
) {
|
||||
eventSequence = 0
|
||||
durableSequence = -1
|
||||
const sessions = input.sessions ?? [session()]
|
||||
const messages =
|
||||
input.currentMessages ??
|
||||
input.sessionMessages ??
|
||||
validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) =>
|
||||
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
|
||||
)
|
||||
const initialStatus = decodeStatus(
|
||||
active &&
|
||||
("info" in active
|
||||
? active.info.role === "assistant" && active.info.time.completed === undefined
|
||||
: active.type === "assistant" && active.time.completed === undefined)
|
||||
? { type: "busy" }
|
||||
: { type: "idle" },
|
||||
decodeOptions,
|
||||
)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const transport = await installSseTransport<EventPayload>(page, {
|
||||
server,
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
const active = messages.findLast((message) => message.type === "assistant")
|
||||
const initialStatus: SessionStatus =
|
||||
active?.type === "assistant" && active.time.completed === undefined ? { type: "busy" } : { type: "idle" }
|
||||
const transport = await installSseTransport(page, { server, retry: input.eventRetry ?? 20 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions,
|
||||
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
||||
pageMessages: () => ({
|
||||
items: messages,
|
||||
}),
|
||||
pageMessages: () => ({ items: messages }),
|
||||
})
|
||||
await page.addInitScript((settings) => {
|
||||
localStorage.setItem(
|
||||
@@ -169,7 +185,7 @@ export async function setupTimeline(
|
||||
mobile: false,
|
||||
})
|
||||
}
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await transport.waitForConnection()
|
||||
if (input.cpuRate && input.cpuRate > 1) {
|
||||
@@ -179,15 +195,25 @@ export async function setupTimeline(
|
||||
|
||||
return {
|
||||
transport,
|
||||
async send(event: TimelineEvent, delay = 0) {
|
||||
const valid = validateTimelineEvent(event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
async send(input: TimelineEvent, delay = 0) {
|
||||
const events = timelineEvents(input)
|
||||
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
|
||||
if (events.length > 1)
|
||||
await transport.burst(
|
||||
events,
|
||||
events.map((item) => ({ marker: describeEvent(item) })),
|
||||
)
|
||||
if (delay) await page.waitForTimeout(delay)
|
||||
},
|
||||
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
||||
for (const item of sequence) {
|
||||
const valid = validateTimelineEvent(item.event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
const events = timelineEvents(item.event)
|
||||
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
|
||||
if (events.length > 1)
|
||||
await transport.burst(
|
||||
events,
|
||||
events.map((event) => ({ marker: describeEvent(event) })),
|
||||
)
|
||||
await page.waitForTimeout(item.delay)
|
||||
}
|
||||
},
|
||||
@@ -207,72 +233,63 @@ export async function setupTimeline(
|
||||
)
|
||||
},
|
||||
async waitForPart(partID: string) {
|
||||
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`)
|
||||
await expect(part).toHaveCount(1)
|
||||
await expect(part).toBeVisible()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvent(event: EventPayload) {
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return [
|
||||
event.payload.type,
|
||||
part.id,
|
||||
part.type === "tool" ? part.tool : part.type,
|
||||
part.type === "tool" ? part.state.status : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
}
|
||||
if (event.payload.type === "session.status") {
|
||||
const status = event.payload.properties.status
|
||||
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":")
|
||||
}
|
||||
return event.payload.type
|
||||
function timelineEvents(input: TimelineEvent) {
|
||||
return (Array.isArray(input) ? input : [input]).map(validateTimelineEvent)
|
||||
}
|
||||
|
||||
export function event<const Type extends TimelinePayload["type"]>(
|
||||
type: Type,
|
||||
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
||||
): TimelineEvent
|
||||
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
||||
return validateTimelineEvent({
|
||||
directory,
|
||||
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
||||
})
|
||||
function describeEvent(event: OpenCodeEvent) {
|
||||
if (event.type.startsWith("session.tool.")) {
|
||||
const data = event.data as { id?: string }
|
||||
return [event.type, data.id].filter(Boolean).join(":")
|
||||
}
|
||||
return event.type
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions) as TimelineEvent
|
||||
export function event(
|
||||
type: "session.status",
|
||||
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
|
||||
): OpenCodeEvent {
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
|
||||
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
|
||||
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
|
||||
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === input.type)
|
||||
if (!definition) throw new Error(`Unknown timeline event: ${input.type}`)
|
||||
return Schema.decodeUnknownSync(definition)(input) as OpenCodeEvent
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
input.forEach((message) => decodeMessage(message, decodeOptions))
|
||||
const messages = [...input]
|
||||
const messages = input.map((message): TimelineMessage => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionMessage.Info)(message)
|
||||
if (decoded.type !== "user" && decoded.type !== "assistant")
|
||||
throw new Error(`Unsupported timeline message type: ${decoded.type}`)
|
||||
return message
|
||||
})
|
||||
const messageIDs = new Set<string>()
|
||||
const partIDs = new Set<string>()
|
||||
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
||||
|
||||
let parentID: string | undefined
|
||||
messages.forEach((message) => {
|
||||
if (messageIDs.has(message.info.id))
|
||||
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
||||
messageIDs.add(message.info.id)
|
||||
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
||||
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
||||
message.parts.forEach((part) => {
|
||||
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
||||
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
||||
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
partIDs.add(part.id)
|
||||
})
|
||||
if (messageIDs.has(message.id)) throw new Error(`Timeline fixture has duplicate message ID: ${message.id}`)
|
||||
messageIDs.add(message.id)
|
||||
if (message.type === "user") parentID = message.id
|
||||
if (message.type === "assistant") {
|
||||
const expected = typeof message.metadata?.parentID === "string" ? message.metadata.parentID : parentID
|
||||
if (!expected || expected !== parentID)
|
||||
throw new Error(`Timeline assistant ${message.id} must reference a parent user in the fixture`)
|
||||
message.content.forEach((part) => {
|
||||
if (part.type !== "tool") return
|
||||
if (partRefs.has(part.id) && partRefs.get(part.id)?.messageID !== message.id)
|
||||
throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
return messages
|
||||
}
|
||||
@@ -334,55 +351,146 @@ export function historyMessages(count: number): TimelineMessage[] {
|
||||
}).flat()
|
||||
}
|
||||
|
||||
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
||||
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
||||
decodePart(owned, decodeOptions)
|
||||
return event("message.part.updated", {
|
||||
sessionID,
|
||||
part: owned,
|
||||
time: 1700000002000,
|
||||
})
|
||||
export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent[] {
|
||||
const messageID = part.messageID ?? assistantID
|
||||
const started = startedParts.has(part.id)
|
||||
const ref = partRef(part.id, messageID, part.type)
|
||||
if (part.type === "text") {
|
||||
startedParts.add(part.id)
|
||||
return [
|
||||
...(started
|
||||
? []
|
||||
: [makeEvent("session.text.started", { sessionID, assistantMessageID: messageID, ordinal: ref.ordinal! })]),
|
||||
makeEvent("session.text.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal!,
|
||||
text: part.text,
|
||||
}),
|
||||
]
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
startedParts.add(part.id)
|
||||
return [
|
||||
...(started
|
||||
? []
|
||||
: [
|
||||
makeEvent("session.reasoning.started", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal!,
|
||||
state: jsonRecord(part.metadata),
|
||||
}),
|
||||
]),
|
||||
makeEvent("session.reasoning.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal!,
|
||||
text: part.text,
|
||||
state: jsonRecord(part.metadata),
|
||||
}),
|
||||
]
|
||||
}
|
||||
return toolEvents(part, messageID)
|
||||
}
|
||||
|
||||
export function renderedPartID(partID: string) {
|
||||
const ref = partRefs.get(partID)
|
||||
if (!ref || ref.type === "tool") return partID
|
||||
return `${ref.messageID}:${ref.type}:${ref.ordinal}`
|
||||
}
|
||||
|
||||
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
||||
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
||||
const ref = partRefs.get(partID)
|
||||
if (!ref || ref.type !== "text" || ref.ordinal === undefined) throw new Error(`Unknown text part: ${partID}`)
|
||||
return makeEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal,
|
||||
delta,
|
||||
})
|
||||
}
|
||||
|
||||
export function messageUpdated(info: Message) {
|
||||
return event("message.updated", { sessionID, info })
|
||||
export function messageUpdated(info: SessionMessageAssistant) {
|
||||
if (info.error)
|
||||
return makeEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: info.id,
|
||||
error: info.error,
|
||||
cost: info.cost,
|
||||
tokens: info.tokens,
|
||||
})
|
||||
return makeEvent("session.step.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: info.id,
|
||||
finish: info.finish ?? "stop",
|
||||
cost: info.cost ?? 0,
|
||||
tokens: info.tokens ?? tokens,
|
||||
})
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
return event("session.status", {
|
||||
if (type === "busy") return makeEvent("session.execution.started", { sessionID })
|
||||
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
|
||||
return makeEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
|
||||
assistantMessageID: assistantID,
|
||||
attempt,
|
||||
at: 1700000010000,
|
||||
error: { type: "provider.error", message: "Rate limited" },
|
||||
})
|
||||
}
|
||||
|
||||
export function stepStarted(message: SessionMessageAssistant) {
|
||||
return makeEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
})
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
parts?: PartSeed<"user">[],
|
||||
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
||||
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
||||
input: { id?: string; summary?: unknown; created?: number } = {},
|
||||
): SessionMessageUser {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
summary: input.summary ?? { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: seeds.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID: id,
|
||||
})),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
return {
|
||||
id,
|
||||
type: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
text: seeds.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: seeds.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const mention = part.source?.text
|
||||
? { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }
|
||||
: undefined
|
||||
return [
|
||||
{
|
||||
data: part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "",
|
||||
mime: part.mime,
|
||||
source: part.url.startsWith("data:")
|
||||
? ({ type: "inline" } as const)
|
||||
: ({ type: "uri", uri: part.source?.path ?? part.url } as const),
|
||||
...(part.filename ? { name: part.filename } : {}),
|
||||
...(mention ? { mention } : {}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: seeds.flatMap((part) => {
|
||||
if (part.type !== "agent") return []
|
||||
return [
|
||||
{
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
|
||||
}
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
@@ -391,49 +499,43 @@ export function assistantMessage(
|
||||
id?: string
|
||||
parentID?: string
|
||||
completed?: boolean
|
||||
error?: AssistantMessage["error"]
|
||||
error?: SessionStructuredError
|
||||
created?: number
|
||||
} = {},
|
||||
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
||||
): SessionMessageAssistant {
|
||||
if (input.error && (typeof input.error.type !== "string" || typeof input.error.message !== "string"))
|
||||
throw new Error("Invalid assistant error")
|
||||
const id = input.id ?? assistantID
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: input.created ?? 1700000001000,
|
||||
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
||||
},
|
||||
parentID: input.parentID ?? userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
},
|
||||
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
const created = input.created ?? 1700000001000
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
const content = parts.map((part) => messageContent(part, id, ordinals))
|
||||
nextOrdinals.set(id, ordinals)
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
metadata: { parentID: input.parentID ?? userID },
|
||||
time: { created, ...(input.completed === false ? {} : { completed: created + 1_000 }) },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
content,
|
||||
cost: 0.01,
|
||||
tokens,
|
||||
...(input.completed === false ? {} : { finish: "stop" as const }),
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function userText(
|
||||
text: string,
|
||||
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
|
||||
): Extract<PartSeed<"user">, { type: "text" }> {
|
||||
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
|
||||
return { id: "prt_user_text", type: "text", text, ...input }
|
||||
}
|
||||
|
||||
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
||||
export function textPart(id: string, text: string): TextSeed {
|
||||
partRef(id, assistantID, "text")
|
||||
return { id, type: "text", text }
|
||||
}
|
||||
|
||||
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
||||
export function reasoningPart(id: string, text: string): ReasoningSeed {
|
||||
partRef(id, assistantID, "reasoning")
|
||||
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
||||
}
|
||||
|
||||
@@ -443,35 +545,35 @@ export function toolPart(
|
||||
state: "pending",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"pending">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "running",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"running">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "completed",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"completed">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "error",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"error">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: ToolStatus,
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
): ToolSeed {
|
||||
const base = { id, type: "tool" as const, callID: id, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
@@ -509,12 +611,7 @@ export function toolPart(
|
||||
}
|
||||
}
|
||||
|
||||
export function shell(
|
||||
id: string,
|
||||
state: ToolStatus,
|
||||
output = "",
|
||||
command = `echo ${id}`,
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
|
||||
if (state === "pending") return toolPart(id, "bash", state, { command })
|
||||
if (state === "running")
|
||||
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
||||
@@ -523,7 +620,7 @@ export function shell(
|
||||
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
||||
}
|
||||
|
||||
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
||||
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
|
||||
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
||||
}
|
||||
|
||||
@@ -551,16 +648,200 @@ export function session(input: Partial<Session> = {}): Session {
|
||||
}
|
||||
}
|
||||
|
||||
function eventSchema<
|
||||
const Type extends TimelinePayload["type"],
|
||||
const Properties extends Schema.Codec<unknown, unknown>,
|
||||
>(type: Type, properties: Properties) {
|
||||
return Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
||||
})
|
||||
function messageContent(
|
||||
part: PartSeed<"assistant">,
|
||||
messageID: string,
|
||||
ordinals: { text: number; reasoning: number },
|
||||
): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "tool") {
|
||||
partRefs.set(part.id, { messageID, type: part.type })
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
} else {
|
||||
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
|
||||
startedParts.add(part.id)
|
||||
}
|
||||
if (part.type === "text") return { type: "text", text: part.text }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
state: jsonRecord(part.metadata),
|
||||
time: part.time
|
||||
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||
: undefined,
|
||||
}
|
||||
const state = part.state
|
||||
const time = "time" in state ? state.time : undefined
|
||||
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: {
|
||||
created: time?.start ?? 1700000001000,
|
||||
...(time?.start === undefined ? {} : { ran: time.start }),
|
||||
...(completed === undefined ? {} : { completed }),
|
||||
},
|
||||
...(part.executed === undefined ? {} : { executed: part.executed }),
|
||||
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
|
||||
if (state.status === "running")
|
||||
return {
|
||||
...base,
|
||||
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
|
||||
}
|
||||
if (state.status === "error")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input: jsonRecord(state.input),
|
||||
error: { type: "ToolError", message: state.error },
|
||||
metadata: jsonRecord(state.metadata),
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: jsonRecord(state.input),
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: jsonRecord(state.metadata),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
|
||||
const previous = toolStates.get(part.callID)
|
||||
if (previous === "completed" || previous === "error") return []
|
||||
|
||||
const events: OpenCodeEvent[] = []
|
||||
if (!previous) {
|
||||
events.push(
|
||||
makeEvent("session.tool.input.started", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.state.status === "pending") {
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
if (!previous || previous === "pending") {
|
||||
events.push(
|
||||
makeEvent("session.tool.input.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
}),
|
||||
makeEvent("session.tool.called", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: part.executed ?? true,
|
||||
state: jsonRecord(part.providerState),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.state.status === "running") {
|
||||
if (previous === "running" || Object.keys(part.state.metadata).length)
|
||||
events.push(
|
||||
makeEvent("session.tool.progress", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
if (part.state.status === "error") {
|
||||
events.push(
|
||||
makeEvent("session.tool.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
error: { type: "ToolError", message: part.state.error },
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
executed: part.executed ?? true,
|
||||
resultState: jsonRecord(part.providerResultState),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
events.push(
|
||||
makeEvent("session.tool.success", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
executed: part.executed ?? true,
|
||||
resultState: jsonRecord(part.providerResultState),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
|
||||
function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef {
|
||||
const current = partRefs.get(id)
|
||||
if (current) return current
|
||||
if (type === "tool") {
|
||||
const ref = { messageID, type } satisfies PartRef
|
||||
partRefs.set(id, ref)
|
||||
return ref
|
||||
}
|
||||
const next = nextOrdinals.get(messageID) ?? { text: 0, reasoning: 0 }
|
||||
const ref = { messageID, type, ordinal: next[type]++ } satisfies PartRef
|
||||
nextOrdinals.set(messageID, next)
|
||||
partRefs.set(id, ref)
|
||||
return ref
|
||||
}
|
||||
|
||||
function makeEvent<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
): OpenCodeEvent {
|
||||
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
|
||||
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
|
||||
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === type)
|
||||
if (!definition) throw new Error(`Unknown timeline event: ${type}`)
|
||||
const input =
|
||||
definition.durability === "durable"
|
||||
? {
|
||||
...base,
|
||||
durable: { aggregateID: sessionID, seq: ++durableSequence, version: definition.durable.version },
|
||||
}
|
||||
: base
|
||||
return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent
|
||||
}
|
||||
|
||||
function jsonRecord(value: Record<string, unknown> | undefined): Record<string, JsonValue> {
|
||||
if (!value) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
const next = jsonValue(item)
|
||||
return next === undefined ? [] : [[key, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): JsonValue | undefined {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
|
||||
if (!value || typeof value !== "object") return
|
||||
return jsonRecord(value as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function provider() {
|
||||
|
||||
@@ -6,7 +6,16 @@ import {
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
|
||||
import {
|
||||
assistantMessage,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_interaction_01_shell"
|
||||
@@ -20,11 +29,20 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
const plan = visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
@@ -76,7 +94,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${renderedPartID(followingID)}"]`])
|
||||
for (const [name, expanded] of [
|
||||
["context-expand", true],
|
||||
["context-collapse", false],
|
||||
@@ -85,7 +103,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
||||
const regions = defineVisualRegions({
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -144,11 +162,22 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const trigger = page
|
||||
.locator(`[data-timeline-part-id="${renderedPartID(editID)}"] [data-slot="collapsible-trigger"]`)
|
||||
.first()
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(editID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
edit: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(editID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
@@ -173,64 +202,6 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
||||
)
|
||||
})
|
||||
|
||||
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
})
|
||||
const nextUserID = "msg_2000_diff_interaction_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
firstUser,
|
||||
assistantMessage(),
|
||||
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_2001_diff_interaction_assistant",
|
||||
parentID: nextUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
const diff = page.locator('[data-timeline-row="DiffSummary"]')
|
||||
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
|
||||
await expect(diff).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
diff: { selector: '[data-timeline-row="DiffSummary"]' },
|
||||
following: { selector: `[data-message-id="${nextUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByText(/show all/i).click()
|
||||
await page.waitForTimeout(500)
|
||||
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"diff-summary-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["diff", "following"] },
|
||||
{ type: "unique", regions: ["diff", "following"] },
|
||||
{ type: "stable", regions: ["diff", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["diff", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
partDelta,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -50,7 +51,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
prt_shell_long: shellRegion(ids[2]),
|
||||
following: shellRegion(followingID),
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(followingID)}"]`])
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.sendAll([
|
||||
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
||||
@@ -60,7 +61,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
|
||||
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant)), delay: 100 },
|
||||
{ event: status("idle"), delay: 700 },
|
||||
])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
@@ -84,9 +85,11 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-pre"]`),
|
||||
).toContainText("line 50")
|
||||
|
||||
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
||||
const short = page.locator(`[data-timeline-part-id="${renderedPartID(ids[1])}"]`)
|
||||
await short.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
||||
@@ -106,26 +109,31 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
const initialReasoning = reasoningPart(reasoningID, "")
|
||||
const initialText = textPart(textID, "Starting")
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
reasoning: {
|
||||
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(reasoningID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(initialReasoning), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
||||
await timeline.waitForPart(reasoningID)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
||||
await timeline.send(partUpdated(initialText), 100)
|
||||
await timeline.send(partDelta(textID, " **stable"), 90)
|
||||
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
||||
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 120)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
@@ -144,7 +152,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
{ type: "flow", regions: ["reasoning", "text"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText("stable output")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,5 +161,5 @@ function lines(count: number) {
|
||||
}
|
||||
|
||||
function shellRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
|
||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_ancestor_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
|
||||
const regions = defineVisualRegions({
|
||||
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
||||
content: { selector: `[data-timeline-part-id="${renderedPartID(partID)}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
@@ -41,13 +41,13 @@ test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_descendant_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
|
||||
await row.evaluate((element) => {
|
||||
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
content: {
|
||||
selector: `[data-timeline-part-id="${partID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||
opacitySelectors: ['[data-probe-opacity="true"]'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -34,8 +35,14 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
||||
@@ -143,8 +150,8 @@ test("tracks keyboard scrolling from a focused timeline descendant", async ({ pa
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
const row = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
|
||||
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
||||
await row.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
@@ -182,7 +189,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
||||
const nested = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-scrollable]`)
|
||||
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await nested.focus()
|
||||
await page.waitForFunction(() => {
|
||||
@@ -209,7 +216,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
|
||||
await nested.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
||||
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
|
||||
await nonOverflowing.evaluate((element) => {
|
||||
element.setAttribute("data-scrollable", "")
|
||||
element.setAttribute("tabindex", "0")
|
||||
@@ -238,12 +245,18 @@ test("jump to latest lands on stable final rows after offscreen growth", async (
|
||||
)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(followingID)}"]`)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
@@ -277,8 +290,14 @@ test("handles a single row taller than the viewport", async ({ page }, testInfo)
|
||||
seedHistory: true,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -68,13 +69,16 @@ for (const profile of profiles) {
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -120,10 +124,19 @@ test("keeps following row stable when a collapsed shell receives 50 lines", asyn
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
||||
@@ -164,10 +177,19 @@ test("keeps rows stable when a running shell becomes an error", async ({ page },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
@@ -215,16 +237,20 @@ test("keeps rows stable when later text arrives before shell output", async ({ p
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
||||
const following = textPart(followingID, "Later assistant content arrived before shell output.")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(shellID)}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
||||
await timeline.send(partUpdated(following), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
@@ -27,7 +27,7 @@ test("adds a task child-session link without replacing the task row", async ({ p
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
||||
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
@@ -53,54 +53,3 @@ test("adds a task child-session link without replacing the task row", async ({ p
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
|
||||
const toolID = "prt_generic_mutation"
|
||||
const followingID = "prt_generic_mutation_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
|
||||
textPart(followingID, "Following generic tool"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
|
||||
200,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
|
||||
400,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"generic-mutation",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
@@ -48,8 +49,8 @@ test.describe("timeline tool state stability", () => {
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
@@ -104,8 +105,10 @@ test.describe("timeline tool state stability", () => {
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toContainText(
|
||||
"Keep it stable",
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
@@ -145,7 +148,7 @@ test.describe("timeline tool state stability", () => {
|
||||
},
|
||||
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
||||
selector: `[data-timeline-part-id="${renderedPartID("prt_ctx_following")}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -194,5 +197,5 @@ function questionInput() {
|
||||
}
|
||||
|
||||
function toolRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
event,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -22,35 +22,6 @@ import {
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_mutation_01_first"
|
||||
const middleID = "prt_mutation_02_middle"
|
||||
const lastID = "prt_mutation_03_last"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||
const textID = "prt_text_reconcile"
|
||||
const followingID = "prt_text_reconcile_following"
|
||||
@@ -59,14 +30,20 @@ test("streams text through growth, canonical replacement, and completion", async
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
const regions = defineVisualRegions({
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
text: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partDelta(textID, " streamed content"), 100)
|
||||
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
|
||||
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
@@ -105,17 +82,23 @@ test("inserts a completed question between stable rows", async ({ page }, testIn
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
first: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(firstID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
last: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(lastID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
||||
600,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
||||
})
|
||||
@@ -132,8 +115,8 @@ test("replaces thinking with an assistant error without a blank turn", async ({
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
messageUpdated({
|
||||
...assistant.info,
|
||||
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
||||
...assistant,
|
||||
error: { type: "APIError", message: "Provider failed visibly" },
|
||||
}),
|
||||
500,
|
||||
)
|
||||
@@ -203,59 +186,6 @@ test("updates retry attempts and long provider messages without remounting the r
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const removeUserID = "msg_0500_remove_user"
|
||||
const removeAssistantID = "msg_0501_remove_assistant"
|
||||
const anchorUserID = "msg_2000_anchor_user"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
|
||||
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
|
||||
id: removeAssistantID,
|
||||
parentID: removeUserID,
|
||||
created: 1690000001000,
|
||||
}),
|
||||
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
|
||||
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
|
||||
id: "msg_2001_anchor_assistant",
|
||||
parentID: anchorUserID,
|
||||
created: 1700000001000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
|
||||
200,
|
||||
)
|
||||
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"historical-turn-remove",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function stablePairPlan(
|
||||
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -11,42 +12,35 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||
const userID = "msg_parent_hydration_user"
|
||||
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
|
||||
const user = {
|
||||
...fixture.messages[fixture.targetID][0]!,
|
||||
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
||||
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_user_${index}`,
|
||||
messageID: userID,
|
||||
})),
|
||||
}
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
||||
...userSeed,
|
||||
id: userID,
|
||||
time: { created: 1700001000000 },
|
||||
} satisfies SessionMessageInfo
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
|
||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||
return {
|
||||
...assistantSeed,
|
||||
info: {
|
||||
...assistantSeed.info,
|
||||
id: messageID,
|
||||
parentID: userID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
},
|
||||
parts: assistantSeed.parts.map((part, partIndex) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
id: messageID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
content: assistantSeed.content.map((part, partIndex) =>
|
||||
part.type === "tool"
|
||||
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
|
||||
: part,
|
||||
),
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
const lastID = userID
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPart = lastAssistant.parts.at(-1)!
|
||||
const lastPart = lastAssistant.content.at(-1)!
|
||||
const lastPartID =
|
||||
lastPart.type === "tool"
|
||||
? lastPart.id
|
||||
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||
: `${lastAssistant.id}:${lastPart.type}:${lastAssistant.content.filter((part) => part.type === lastPart.type).length - 1}`
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
@@ -107,30 +101,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
},
|
||||
pageMessages: (sessionID, limit, before) => {
|
||||
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
||||
const end = before ? items.findIndex((message) => message.id === before) : items.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||
return route.fulfill({
|
||||
await page.route(`**/api/session/${fixture.targetID}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
current
|
||||
? {
|
||||
data: {
|
||||
...target,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: target.directory },
|
||||
},
|
||||
}
|
||||
: target,
|
||||
),
|
||||
})
|
||||
})
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
...target,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: target.directory },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -148,8 +137,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
{ href, title: target.title },
|
||||
)
|
||||
const metrics = await measureSessionSwitch(page, {
|
||||
destinationIDs: messages.map((message) => message.info.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
destinationIDs: messages.map((message) => message.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||
lastID,
|
||||
requiredPartID: lastPartID,
|
||||
requireBottomAnchor: false,
|
||||
|
||||
@@ -32,8 +32,8 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
||||
|
||||
await installCachedRepaintProbe(page, {
|
||||
targetHref: stressSessionHref(fixture.targetID),
|
||||
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
||||
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
destination: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
source: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||
last: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
windowMs: 1_000,
|
||||
})
|
||||
|
||||
@@ -60,8 +60,8 @@ async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "o
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
|
||||
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureSessionSwitch(page, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||
@@ -10,37 +11,20 @@ const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
const editPartID = "prt_0001_edit"
|
||||
export const textPartID = "prt_9999_text"
|
||||
export const textPartID = `${assistantMessageID}:text:0`
|
||||
const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
type EventPayload = OpenCodeEvent
|
||||
|
||||
const userMessage = {
|
||||
info: {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Please edit the file.",
|
||||
},
|
||||
],
|
||||
}
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const editPart = {
|
||||
const editPart: ToolSeed = {
|
||||
id: editPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
@@ -66,32 +50,16 @@ const editPart = {
|
||||
},
|
||||
}
|
||||
|
||||
const streamedTextPart = {
|
||||
id: textPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
info: {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
parentID: userMessageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
},
|
||||
parts: [editPart],
|
||||
}
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
export async function setupTimelineBenchmark(
|
||||
page: Page,
|
||||
@@ -105,7 +73,7 @@ export async function setupTimelineBenchmark(
|
||||
const events: EventPayload[] = []
|
||||
let eventBatch = options.eventBatch
|
||||
const currentUserMessage = options.turnDiffs
|
||||
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
|
||||
? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } }
|
||||
: userMessage
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -138,7 +106,8 @@ export async function setupTimelineBenchmark(
|
||||
await page.setViewportSize({ width: 1366, height: 768 })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expectAppVisible(scroller)
|
||||
return {
|
||||
@@ -182,34 +151,27 @@ export async function setupTimelineBenchmark(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
...streamedTextPart,
|
||||
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
|
||||
return [
|
||||
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||
return Array.from({ length: deltaCount }, (_, index) => ({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
messageID: assistantMessageID,
|
||||
partID: textPartID,
|
||||
field: "text",
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
},
|
||||
},
|
||||
}))
|
||||
return Array.from({ length: deltaCount }, (_, index) =>
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function performanceTurn(index: number) {
|
||||
@@ -315,48 +277,92 @@ function performanceTurn(index: number) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
] as unknown as ContentSeed[]
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1690000000000 + index * 2_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `prt_0000_${suffix}_user`,
|
||||
sessionID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: `Historical prompt ${index}`,
|
||||
},
|
||||
],
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1690000000000 + index * 2_000 },
|
||||
text: `Historical prompt ${index}`,
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||
parentID: userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts,
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map((part) => {
|
||||
if (part.type === "text") return { type: "text" as const, text: part.text }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning" as const,
|
||||
text: part.text,
|
||||
time: { created: part.time.start, completed: part.time.end },
|
||||
}
|
||||
return toolContent(part)
|
||||
}),
|
||||
},
|
||||
]
|
||||
] satisfies SessionMessageInfo[]
|
||||
}
|
||||
|
||||
type ToolSeed = {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: string
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title?: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
|
||||
|
||||
type ContentSeed =
|
||||
| (ContentSeedBase & { type: "text"; text: string })
|
||||
| (ContentSeedBase & { type: "reasoning"; text: string; time: { start: number; end: number } })
|
||||
| ToolSeed
|
||||
|
||||
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let eventSequence = 0
|
||||
|
||||
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
durable = false,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
}
|
||||
|
||||
function historicalMarkdown(index: number) {
|
||||
|
||||
@@ -28,9 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
type MessagePart = {
|
||||
id: string
|
||||
type: "text" | "reasoning" | "tool"
|
||||
text?: string
|
||||
time?: { start: number; end?: number }
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title: unknown
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
function assistantMessage(
|
||||
_sessionID: string,
|
||||
index: number,
|
||||
_parentID: string,
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "text") return { type: "text", text: part.text ?? "" }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text ?? "",
|
||||
time: part.time
|
||||
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||
: undefined,
|
||||
}
|
||||
const state = part.state!
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID ?? part.id,
|
||||
name: part.tool!,
|
||||
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +218,7 @@ function code(seed: number, lines: number, width = 32) {
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
function turn(index: number): SessionMessageInfo[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
@@ -241,7 +259,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
@@ -267,19 +285,12 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
userMessage(childID, index + 2000, 120),
|
||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = {
|
||||
const messages: Record<string, SessionMessageInfo[]> = {
|
||||
[sourceID]: sourceMessages,
|
||||
[targetID]: targetMessages,
|
||||
[childID]: childMessages,
|
||||
}
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
@@ -339,14 +350,19 @@ export const fixture = {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
childTitle: "Inspect child navigation",
|
||||
sourceMessageIDs: sourceMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
|
||||
sourceMessageIDs: sourceMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
|
||||
if (part.type === "reasoning")
|
||||
return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
|
||||
return [part.id]
|
||||
})
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -355,12 +371,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
messages.findIndex((message) => message.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start].id : undefined,
|
||||
}
|
||||
}
|
||||
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -1,121 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("preserves current messages", () => {
|
||||
const message = {
|
||||
id: "msg_current",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "current",
|
||||
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
expect(currentMessage(message)).toBe(message)
|
||||
})
|
||||
|
||||
test("converts rich legacy messages to current message types", () => {
|
||||
expect(
|
||||
currentMessage({
|
||||
info: { id: "msg_user", role: "user", time: { created: 1 } },
|
||||
parts: [
|
||||
{ type: "text", text: "Use @src/a.ts with @explore" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "data.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
|
||||
},
|
||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "Use @src/a.ts with @explore",
|
||||
files: [
|
||||
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
name: "a.ts",
|
||||
source: { type: "uri", uri: "src/a.ts" },
|
||||
mention: { text: "@src/a.ts", start: 4, end: 13 },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
|
||||
})
|
||||
|
||||
expect(
|
||||
currentMessage({
|
||||
info: {
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
variant: "high",
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
|
||||
{
|
||||
id: "prt_tool",
|
||||
callID: "call_tool",
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
output: "contents",
|
||||
metadata: { title: "a.ts" },
|
||||
time: { start: 3, end: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
content: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_tool",
|
||||
name: "read",
|
||||
time: { created: 3, ran: 3, completed: 4 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
content: [{ type: "text", text: "contents" }],
|
||||
metadata: { title: "a.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type VisualStabilityTrace,
|
||||
} from "../../utils/visual-stability"
|
||||
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
||||
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
||||
|
||||
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
||||
@@ -346,19 +346,6 @@ test("evaluates the typed invariant algebra over explicit observations", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("legacy plan adapter preserves analyzer messages and order", () => {
|
||||
const input = trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
])
|
||||
const options = { flow: ["changing", "following"], stable: ["changing"] }
|
||||
|
||||
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
|
||||
analyzeVisualStability(input, options),
|
||||
)
|
||||
})
|
||||
|
||||
function frame(
|
||||
at: number,
|
||||
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
|
||||
@@ -38,30 +38,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||
expect(
|
||||
requests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
||||
await mockServers(page, [])
|
||||
await page.addInitScript(
|
||||
({ serverB, sessionB }) => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||
)
|
||||
},
|
||||
{ serverB, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
@@ -91,40 +67,26 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
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,
|
||||
worktree: current.directory,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
|
||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||
const projectID = "proj_prompt_input_v2_editing"
|
||||
const sessionID = "ses_prompt_input_v2_editing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -32,7 +33,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
await expectAppVisible(composer)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
|
||||
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
|
||||
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 V2 thinking level control while relevant", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -49,7 +50,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
@@ -49,7 +49,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
const transport = await installSseTransport(page, {
|
||||
server: serverA,
|
||||
retry: 20,
|
||||
})
|
||||
@@ -82,18 +82,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a",
|
||||
sessionID: sessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git status"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
id: "evt_permission_background_a",
|
||||
created: 1700000001000,
|
||||
type: "permission.asked",
|
||||
location: { directory: directoryA },
|
||||
data: {
|
||||
id: "permission-background-a",
|
||||
sessionID: sessionA.id,
|
||||
action: "bash",
|
||||
resources: ["git status"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -110,18 +109,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
])
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a-child",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a-child",
|
||||
sessionID: childSessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
id: "evt_permission_background_a_child",
|
||||
created: 1700000002000,
|
||||
type: "permission.asked",
|
||||
location: { directory: directoryA },
|
||||
data: {
|
||||
id: "permission-background-a-child",
|
||||
sessionID: childSessionA.id,
|
||||
action: "bash",
|
||||
resources: ["git diff"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -226,38 +224,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (url.pathname === "/permission") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, [])
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: remote ? sessionB.projectID : "project-server-a",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: directory,
|
||||
config: directory,
|
||||
worktree: directory,
|
||||
directory,
|
||||
home: directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -57,47 +57,33 @@ async function mockServers(page: Page) {
|
||||
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, url.pathname === "/api/event")
|
||||
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 ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
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,
|
||||
worktree: current.directory,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
@@ -116,10 +102,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route, current: boolean) {
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const directory = "C:/OpenCode/ReviewImageFlashRegression"
|
||||
const sessionID = "ses_review_image_flash_regression"
|
||||
const title = "Review image flash regression"
|
||||
const imageFile = "assets/preview.png"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
||||
await openReview(page)
|
||||
@@ -109,30 +110,16 @@ async function openReview(page: Page) {
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_image_flash_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_image_flash_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_image_flash_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
id: "msg_review_image_flash_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
||||
const sessionID = "ses_review_line_comment_regression"
|
||||
const title = "Review line comment regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openReview(page)
|
||||
@@ -88,7 +89,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 700, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_line_comment_regression",
|
||||
@@ -123,30 +123,16 @@ async function openReview(page: Page) {
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_line_comment_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_line_comment_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_line_comment_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
id: "msg_review_line_comment_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const changes = page.getByRole("tab", { name: "Changes" })
|
||||
const diffResponse = page.waitForResponse(
|
||||
|
||||
@@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -54,7 +54,6 @@ async function switchSession(page: Page, title: string) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/ReviewTerminalStacked"
|
||||
const projectID = "proj_review_terminal_stacked"
|
||||
const sessionID = "ses_review_terminal_stacked"
|
||||
const title = "Review terminal stacked"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const branchDiffs = [
|
||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||
...Array.from({ length: 2_739 }, (_, index) =>
|
||||
@@ -21,7 +22,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
test.setTimeout(120_000)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -130,8 +130,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
@@ -16,8 +16,7 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||
const pathBlocked = new Promise<void>((resolve) => {
|
||||
releasePath = resolve
|
||||
})
|
||||
await page.route("**/api/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||
await page.route("**/api/location*", async (route) => {
|
||||
await pathBlocked
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ const directory = "C:/OpenCode/RequestDocks"
|
||||
const projectID = "proj_request_docks"
|
||||
const sessionID = "ses_request_docks"
|
||||
const title = "Request dock regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows a pending question dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
@@ -34,7 +35,7 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
@@ -92,7 +93,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||
@@ -111,11 +112,11 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
const transport = await installSseTransport(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
server,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServer(page, { forms: [] })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
@@ -138,26 +139,26 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "form.created",
|
||||
properties: {
|
||||
form: {
|
||||
id: "frm_question_caret",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "string",
|
||||
title: "Continue",
|
||||
description: "Continue?",
|
||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
id: "evt_form_created",
|
||||
created: 1700000001000,
|
||||
type: "form.created",
|
||||
location: { directory },
|
||||
data: {
|
||||
form: {
|
||||
id: "frm_question_caret",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "string",
|
||||
title: "Continue",
|
||||
description: "Continue?",
|
||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -166,11 +167,11 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "form.cancelled",
|
||||
properties: { sessionID, id: "frm_question_caret" },
|
||||
},
|
||||
id: "evt_form_cancelled",
|
||||
created: 1700000002000,
|
||||
type: "form.cancelled",
|
||||
location: { directory },
|
||||
data: { sessionID, id: "frm_question_caret" },
|
||||
})
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
@@ -183,13 +184,11 @@ async function mockServer(
|
||||
page: Page,
|
||||
requests: {
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
sessionStatus?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -229,7 +228,6 @@ async function mockServer(
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
permissions: requests.permissions,
|
||||
questions: requests.questions,
|
||||
forms: requests.forms,
|
||||
sessionStatus: requests.sessionStatus,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,14 +9,11 @@ const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
const editPartID = "prt_0001_edit"
|
||||
const textPartID = "prt_9999_text"
|
||||
const textPartID = `${assistantMessageID}:text:0`
|
||||
const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
type EventPayload = OpenCodeEvent
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -27,25 +25,11 @@ declare global {
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
info: {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Please edit the file.",
|
||||
},
|
||||
],
|
||||
}
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const editPart = {
|
||||
id: editPartID,
|
||||
@@ -74,31 +58,19 @@ const editPart = {
|
||||
}
|
||||
|
||||
const streamedTextPart = {
|
||||
id: textPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
info: {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
parentID: userMessageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
},
|
||||
parts: [editPart],
|
||||
}
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
test.describe("regression: session timeline local row state", () => {
|
||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||
@@ -106,7 +78,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await mockServer(page, events)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
@@ -119,15 +91,11 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||
await expectExpanded(wrapper, false)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
events.push(...textEvents())
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
expect(await readToolState(page)).toEqual({
|
||||
expanded: false,
|
||||
@@ -136,13 +104,13 @@ test.describe("regression: session timeline local row state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
|
||||
test("does not remount an edit diff when a sibling part arrives", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await installDiffProbe(page)
|
||||
await mockServer(page, events)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
@@ -151,15 +119,11 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectAppVisible(file)
|
||||
await markDiffProbe(page)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
events.push(...textEvents())
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
const siblingProbe = await readDiffProbe(page)
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
@@ -169,27 +133,6 @@ test.describe("regression: session timeline local row state", () => {
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
})
|
||||
|
||||
await markDiffProbe(page)
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: editPartWithAdditions(2) },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
expect(await readDiffProbe(page)).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
||||
@@ -216,10 +159,10 @@ test.describe("regression: session timeline local row state", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
|
||||
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
@@ -293,7 +236,7 @@ async function readToolState(page: Page) {
|
||||
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
||||
}),
|
||||
textPartID,
|
||||
`${assistantMessageID}:text:0`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -355,22 +298,103 @@ async function readDiffProbe(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
function editPartWithAdditions(additions: number) {
|
||||
function toolContent(part: typeof editPart): SessionMessageAssistant["content"][number] {
|
||||
return {
|
||||
...editPart,
|
||||
type: "tool",
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||
state: {
|
||||
...editPart.state,
|
||||
metadata: {
|
||||
...editPart.state.metadata,
|
||||
filediff: {
|
||||
...editPart.state.metadata.filediff,
|
||||
additions,
|
||||
},
|
||||
},
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let eventSequence = -1
|
||||
|
||||
function textEvents(): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
|
||||
eventValue(
|
||||
"session.text.ended",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: streamedTextPart.text,
|
||||
},
|
||||
1,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.input.started",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.input.ended",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.called",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: true,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_collapse_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
@@ -385,7 +409,12 @@ function readExpanded(element: Element) {
|
||||
return !!content && content.getBoundingClientRect().height > 0
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: EventPayload[],
|
||||
messages: SessionMessageInfo[] = [userMessage, assistantMessage],
|
||||
) {
|
||||
eventSequence = -1
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
@@ -437,3 +466,8 @@ function provider() {
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function sessionHref() {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
@@ -17,11 +18,6 @@ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "ma
|
||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||
|
||||
type Message = {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
parts: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
||||
|
||||
test.describe("regression: session timeline context group resize", () => {
|
||||
@@ -30,7 +26,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await mockServer(page)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
|
||||
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
|
||||
@@ -45,7 +41,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
})
|
||||
|
||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page, events, [
|
||||
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
||||
@@ -53,7 +49,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
@@ -75,25 +71,21 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
events.push(
|
||||
...toolEvents(
|
||||
contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
@@ -211,74 +203,51 @@ async function sampleExpansion(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): SessionMessageInfo[] {
|
||||
const userID = id("msg_user", index)
|
||||
const assistantID = id("msg_assistant", index)
|
||||
const content: SessionMessageAssistant["content"] = target
|
||||
? [
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status)),
|
||||
{ type: "text", text: "This assistant text is immediately after the explored context group." },
|
||||
]
|
||||
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: `User message ${index}`,
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||
parentID: userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts: target
|
||||
? [
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: "This assistant text is immediately after the explored context group.",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: id("prt_text", index),
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
|
||||
},
|
||||
],
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -308,11 +277,76 @@ function contextTool(
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
||||
fixtureMessages = messages,
|
||||
) {
|
||||
type ContextTool = ReturnType<typeof contextTool>
|
||||
|
||||
function toolContent(part: ContextTool): SessionMessageAssistant["content"][number] {
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: {
|
||||
created: part.state.time.start,
|
||||
ran: part.state.time.start,
|
||||
...(part.state.status === "completed" ? { completed: part.state.time.end } : {}),
|
||||
},
|
||||
}
|
||||
if (part.state.status === "running")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "running",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let eventSequence = -1
|
||||
|
||||
function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_context_resize_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
|
||||
eventSequence = -1
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
@@ -372,3 +406,8 @@ function provider() {
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function sessionHref() {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
|
||||
const editID = "prt_diagnostics_edit"
|
||||
const base = editPart(editID, [])
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([base])],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(
|
||||
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(editPart(editID, [])), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
@@ -68,25 +43,6 @@ function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
}
|
||||
}
|
||||
|
||||
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
|
||||
diagnostics,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function diagnostic(message: string, line: number) {
|
||||
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
@@ -17,6 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 200
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
@@ -29,19 +31,19 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
}),
|
||||
]
|
||||
}).flat()
|
||||
const assistants = messages.filter((message) => message.info.role === "assistant")
|
||||
const assistants = messages.filter((message): message is SessionMessageAssistant => message.type === "assistant")
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
|
||||
const userPartID = `${messages.at(-2)!.info.id}:text:0`
|
||||
const lastPartID = `${assistants.at(-1)!.id}:text:0`
|
||||
const userPartID = `${messages.at(-2)!.id}:text:0`
|
||||
const completed = {
|
||||
...lastAssistant.info,
|
||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||
...lastAssistant,
|
||||
time: { ...lastAssistant.time, completed: lastAssistant.time.created + 15_000 },
|
||||
}
|
||||
const scenarios = [
|
||||
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||
{
|
||||
name: "interruption",
|
||||
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
||||
info: { ...completed, error: { type: "MessageAbortedError", message: "Stopped" } },
|
||||
idleFirst: true,
|
||||
interrupted: true,
|
||||
},
|
||||
@@ -56,12 +58,11 @@ for (const scenario of scenarios) {
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
const sequence: string[] = []
|
||||
const history = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
const transport = await installSseTransport(page, {
|
||||
server,
|
||||
retry: 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: {
|
||||
@@ -94,15 +95,15 @@ for (const scenario of scenarios) {
|
||||
},
|
||||
message: (requestedSessionID, messageID) => {
|
||||
if (requestedSessionID !== sessionID) return
|
||||
return messages.find((item) => item.info.id === messageID)
|
||||
return messages.find((item) => item.id === messageID)
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
pages.push({ before, limit })
|
||||
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
cursor: start > 0 ? messages[start]!.id : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -152,7 +153,7 @@ for (const scenario of scenarios) {
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||
@@ -169,7 +170,7 @@ for (const scenario of scenarios) {
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
||||
`messages:start:${messages.at(-messagePageSize)!.id}`,
|
||||
])
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
@@ -187,7 +188,7 @@ for (const scenario of scenarios) {
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
stepStarted,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
@@ -65,7 +67,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
@@ -97,14 +99,16 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await timeline.send(stepStarted(assistant), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
|
||||
"Recovered response",
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
|
||||
@@ -39,7 +39,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [
|
||||
sessionMessages: [
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
assistant(true),
|
||||
@@ -74,7 +74,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
|
||||
const card = page.locator('[data-component="task-tool-card"]')
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toContainText("Inspect code")
|
||||
@@ -92,14 +92,14 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("waits for completion before labeling requested background work", async ({ page }) => {
|
||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true, undefined, true)] })
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [user, assistant(false, true, childID)],
|
||||
sessionMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
@@ -113,7 +113,7 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
test("shows a badge for active background work", async ({ page }) => {
|
||||
const childID = "ses_background_child"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [user, assistant(true)],
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
@@ -125,7 +125,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
const timeline = await setupTimeline(page, {
|
||||
currentMessages: [
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_backgrounded",
|
||||
@@ -203,7 +203,12 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||
).toHaveAttribute("data-active", "true")
|
||||
|
||||
await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } }))
|
||||
await timeline.transport.send({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: backgroundID },
|
||||
} as never)
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
@@ -71,33 +71,20 @@ test.describe("session timeline projection", () => {
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Keep this stable", { id: "prt_comment" }),
|
||||
userText("Continue after the comment", { id: "prt_visible_user" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
||||
isRetryable: false,
|
||||
},
|
||||
type: "APIError",
|
||||
message: "Visible provider failure",
|
||||
},
|
||||
created: 1700000003000,
|
||||
})
|
||||
@@ -122,48 +109,11 @@ test.describe("session timeline projection", () => {
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment_only",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_comment_visible" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
|
||||
const nextAssistant = assistantMessage([], {
|
||||
id: "msg_2001_diff_next_assistant",
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -36,8 +37,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
"Final after early idle",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates expanded web search links without resetting expansion", async ({ page }) => {
|
||||
const searchID = "prt_websearch_mutation"
|
||||
const input = { query: "timeline stability" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(searchID, "websearch", "completed", input, {
|
||||
output: "https://example.com/one\nhttps://example.com/two",
|
||||
}),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
|
||||
const toolID = "prt_duplicate_error"
|
||||
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(partUpdated(failed), 150)
|
||||
await timeline.send(partUpdated(failed), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Command failed visibly")
|
||||
})
|
||||
|
||||
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
|
||||
const questionID = "prt_multi_question"
|
||||
const input = {
|
||||
questions: [
|
||||
{ header: "First", question: "First choice?", options: [] },
|
||||
{ header: "Second", question: "Second choice?", options: [], multiple: true },
|
||||
],
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Updated")
|
||||
await expect(wrapper).toContainText("B, C")
|
||||
})
|
||||
@@ -1,43 +1,44 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
||||
import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
||||
const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)!
|
||||
const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)!
|
||||
|
||||
await timeline.waitForPart("prt_transport_first")
|
||||
await timeline.waitForPart("prt_transport_second")
|
||||
expect(first.connectionID).toBe(second.connectionID)
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const acknowledgements = await timeline.transport.burst([
|
||||
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
...partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
...partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
])
|
||||
|
||||
await timeline.waitForPart("prt_transport_burst_a")
|
||||
await timeline.waitForPart("prt_transport_burst_b")
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4)
|
||||
})
|
||||
|
||||
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
await timeline.transport.send(started!)
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const snowman = new TextEncoder().encode("\u2603")[0]!
|
||||
const multibyte = encoded.indexOf(snowman)
|
||||
|
||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
||||
const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2])
|
||||
|
||||
await timeline.waitForPart("prt_transport_split")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText(
|
||||
"split snowman \u2603\u2603\u2603",
|
||||
)
|
||||
expect(acknowledgement.chunkCount).toBe(4)
|
||||
@@ -46,12 +47,11 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const partID = "prt_transport_heartbeat_sentinel"
|
||||
const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
|
||||
const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)!
|
||||
await timeline.waitForPart(partID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
|
||||
"data-markdown-ready",
|
||||
"",
|
||||
)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`),
|
||||
).toHaveAttribute("data-markdown-ready", "")
|
||||
const before = await timelineRows(page)
|
||||
const heartbeat = await timeline.transport.heartbeat()
|
||||
|
||||
@@ -66,7 +66,7 @@ test("reconnects after a clean close", async ({ page }) => {
|
||||
|
||||
await timeline.transport.close()
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
await timeline.transport.burst(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_close")
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
@@ -79,7 +79,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
await timeline.transport.error("contract failure")
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
||||
await timeline.transport.burst(partUpdated(textPart("prt_transport_error", "after error")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_error")
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||
@@ -89,9 +89,13 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
const events = partUpdated(textPart("prt_transport_id", "event with id"))
|
||||
const first = (
|
||||
await timeline.transport.burst(
|
||||
events,
|
||||
events.map((_, index) => (index === events.length - 1 ? { id: "timeline-event-7" } : {})),
|
||||
)
|
||||
).at(-1)!
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
sessionStatus: () => sessionStatus,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
sessionStatus[sourceID] = { type: "busy" }
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -12,8 +13,6 @@ const childTitle = "Subagent child session"
|
||||
// Child session pages derive their heading from the task part that spawned them.
|
||||
const taskDescription = "Inspect child navigation"
|
||||
|
||||
type EventPayload = { directory: string; payload: Record<string, unknown> }
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
||||
@@ -27,7 +26,7 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
|
||||
test("keeps the parent visible while the child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
@@ -52,14 +51,18 @@ test("keeps the parent visible while child lineage resolves", async ({ page }) =
|
||||
})
|
||||
|
||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await setup(page, () => events.splice(0, 1))
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: { type: "session.deleted", properties: { info: childSession() } },
|
||||
id: "evt_session_deleted",
|
||||
created: 1700000003000,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: childID, seq: 1, version: 2 },
|
||||
location: { directory },
|
||||
data: { sessionID: childID },
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
@@ -67,7 +70,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -142,60 +145,36 @@ function childSession() {
|
||||
return session(childID, childTitle, 1700000001000, { parentID })
|
||||
}
|
||||
|
||||
function parentMessages() {
|
||||
function parentMessages(): SessionMessageInfo[] {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID: parentID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text_0001",
|
||||
sessionID: parentID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: "Delegate work to a subagent",
|
||||
},
|
||||
],
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Delegate work to a subagent",
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID: parentID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000002000 },
|
||||
parentID: userID,
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "opencode",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000002000 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: [
|
||||
{
|
||||
id: "prt_tool_task_0001",
|
||||
sessionID: parentID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: "call_task_0001",
|
||||
tool: "task",
|
||||
id: "call_task_0001",
|
||||
name: "task",
|
||||
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: taskDescription, subagent_type: "explore" },
|
||||
output: "Subagent finished",
|
||||
title: taskDescription,
|
||||
content: [{ type: "text", text: "Subagent finished" }],
|
||||
metadata: { sessionId: childID },
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,8 +85,7 @@ async function mockServer(page: Page) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
@@ -95,41 +94,29 @@ async function mockServer(page: Page) {
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: sessionA.projectID,
|
||||
worktree: sessionA.directory,
|
||||
canonical: sessionA.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
return json(
|
||||
route,
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -8,12 +8,12 @@ const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -74,7 +74,7 @@ test.beforeEach(async ({ page }) => {
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -113,7 +113,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
||||
})
|
||||
await seedCachedTerminal(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -139,7 +139,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -184,7 +184,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -211,12 +211,18 @@ function seedCachedTerminal(page: Page) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||
{ terminalKey: terminalStorageKey(), ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
function terminalStorageKey() {
|
||||
const dir = base64Encode(directory)
|
||||
const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal`
|
||||
}
|
||||
|
||||
function ptyLocation() {
|
||||
return { directory, project: { id: projectID, directory } }
|
||||
return { directory, project: { id: projectID, directory, canonical: directory } }
|
||||
}
|
||||
|
||||
function ptyInfo(id: string, title: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/HiddenTerminalRegression"
|
||||
const projectID = "proj_hidden_terminal_regression"
|
||||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
@@ -91,7 +92,7 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
|
||||
@@ -21,16 +21,29 @@ const words = [
|
||||
"vector",
|
||||
]
|
||||
|
||||
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
type MessagePart = {
|
||||
id: string
|
||||
type: "text" | "reasoning" | "tool"
|
||||
text?: string
|
||||
time?: { start: number; end?: number }
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title: unknown
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
function assistantMessage(
|
||||
_sessionID: string,
|
||||
index: number,
|
||||
_parentID: string,
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "text") return { type: "text", text: part.text ?? "" }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text ?? "",
|
||||
time: part.time
|
||||
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||
: undefined,
|
||||
}
|
||||
const state = part.state!
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID ?? part.id,
|
||||
name: part.tool!,
|
||||
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +198,7 @@ function code(seed: number, lines: number) {
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
function turn(index: number): SessionMessageInfo[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
@@ -219,7 +237,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||
@@ -227,22 +245,16 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||
const messages: Record<string, SessionMessageInfo[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function currentPartIDs(message: Message) {
|
||||
function currentPartIDs(message: SessionMessageInfo) {
|
||||
if (message.type === "user") return message.text.trim() ? [`${message.id}:text:0`] : []
|
||||
if (message.type !== "assistant") return []
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.parts.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
|
||||
if (part.type === "reasoning") return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
|
||||
if (part.type === "tool") return [part.id]
|
||||
return []
|
||||
})
|
||||
}
|
||||
@@ -295,12 +307,11 @@ export const fixture = {
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
|
||||
.callID,
|
||||
expandedShellPartID: targetMessages
|
||||
.flatMap((message) => (message.type === "assistant" ? message.content : []))
|
||||
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,12 +320,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
messages.findIndex((message) => message.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start].id : undefined,
|
||||
}
|
||||
}
|
||||
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -33,7 +33,6 @@ test.describe("smoke: session timeline", () => {
|
||||
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -44,7 +43,7 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await waitForTimelineStable(page)
|
||||
const scroller = timelineScroller(page)
|
||||
await pointAtTimeline(page)
|
||||
@@ -92,7 +91,6 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("preserves the timeline gap above the composer", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -101,7 +99,7 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await waitForTimelineStable(page)
|
||||
const scroller = timelineScroller(page)
|
||||
await scroller.evaluate((element) => {
|
||||
@@ -119,7 +117,6 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -143,11 +140,11 @@ test.describe("smoke: session timeline", () => {
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.targetID}`)
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
@@ -262,7 +259,6 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("paints a cold session tab at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -285,10 +281,10 @@ test.describe("smoke: session timeline", () => {
|
||||
},
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
const ids = new Set(destination)
|
||||
@@ -341,7 +337,6 @@ test.describe("smoke: session timeline", () => {
|
||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -351,9 +346,9 @@ test.describe("smoke: session timeline", () => {
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await selectHomeProject(page, fixture.project.name)
|
||||
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await expectSessionReady(page)
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
@@ -742,8 +737,8 @@ async function selectHomeProject(page: Page, projectName: string) {
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
||||
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionId}`)
|
||||
await expectSessionTitle(page, expectedTitle)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_model_selection_flow",
|
||||
|
||||
@@ -6,6 +6,9 @@ export function trackPageErrors(page: Page) {
|
||||
if (message.type() === "error") errors.push(message.text())
|
||||
})
|
||||
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`)
|
||||
})
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import type {
|
||||
JsonValue,
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
onInstanceDispose?: () => void
|
||||
directory: string
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
pageMessages: (
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
before?: string,
|
||||
) => {
|
||||
items: SessionMessageInfo[]
|
||||
cursor?: string
|
||||
}
|
||||
vcsDiff?: unknown[]
|
||||
messageDelay?: number
|
||||
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
message?: (sessionID: string, messageID: string) => unknown
|
||||
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
events?: () => OpenCodeEvent[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
@@ -46,10 +39,11 @@ type MockStreamWindow = Window & {
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
let nextCursor = 0
|
||||
|
||||
await page.addInitScript(
|
||||
({ port, retry }) => {
|
||||
({ server, retry }) => {
|
||||
const host = window as MockStreamWindow
|
||||
if (host.__testSseTransport || host.__mockServerStream) return
|
||||
const originalFetch = window.fetch.bind(window)
|
||||
@@ -74,7 +68,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
|
||||
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
|
||||
state.connections += 1
|
||||
const id = state.connections
|
||||
let ended = false
|
||||
@@ -114,7 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||
},
|
||||
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
|
||||
{ server, retry: config.eventRetry },
|
||||
)
|
||||
|
||||
if (config.events) {
|
||||
@@ -125,10 +119,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (batch.length === 0) return
|
||||
pump.busy = true
|
||||
void page
|
||||
.evaluate(
|
||||
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
|
||||
batch.map(currentEvent),
|
||||
)
|
||||
.evaluate((payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), batch as unknown[])
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pump.busy = false
|
||||
@@ -136,87 +127,33 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
},
|
||||
"/project": [config.project],
|
||||
"/project/current": config.project,
|
||||
"/agent": [{ name: "build", mode: "primary" }],
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
if (path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/provider") return json(route, providerConfig(config))
|
||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||
if (legacyAuth && route.request().method() === "PUT") {
|
||||
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||
config.onInstanceDispose?.()
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status")
|
||||
return json(
|
||||
route,
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||
)
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (path === "/file" && config.fileList)
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/find/file" && config.findFiles)
|
||||
return json(
|
||||
route,
|
||||
await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
)
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
project: {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
},
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
@@ -245,6 +182,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
@@ -277,7 +215,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
])
|
||||
}
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
return json(route, {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
})
|
||||
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
||||
if (worktree && route.request().method() === "GET")
|
||||
return json(route, [
|
||||
@@ -303,18 +245,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
if (path === "/api/question/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||
})
|
||||
if (path === "/api/form/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
})
|
||||
if (path === "/api/vcs")
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/fs/list" && config.fileList)
|
||||
@@ -352,10 +289,26 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
: entries,
|
||||
})
|
||||
}
|
||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/shell" && route.request().method() === "GET")
|
||||
return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (path === "/api/session") {
|
||||
if (route.request().method() === "POST") {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>
|
||||
const created = currentSession(
|
||||
{
|
||||
id: "ses_mock_created",
|
||||
projectID: (config.project as { id?: string }).id,
|
||||
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||
},
|
||||
config.directory,
|
||||
)
|
||||
config.sessions.push(created)
|
||||
return json(route, { data: created })
|
||||
}
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
@@ -365,7 +318,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
if (parentID === null) return true
|
||||
if (parentID === "null") return session.parentID === undefined
|
||||
return session.parentID === parentID
|
||||
})
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
@@ -375,7 +332,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
.includes(search)
|
||||
)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||
return json(route, {
|
||||
@@ -398,9 +355,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
||||
if (sessionForm && route.request().method() === "GET") {
|
||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||
@@ -413,15 +367,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
||||
return json(route, { data: [] })
|
||||
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
|
||||
if (sessionPermission && route.request().method() === "GET") {
|
||||
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||
return json(route, {
|
||||
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
@@ -429,10 +386,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||
@@ -442,69 +395,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (currentMessageMatch) {
|
||||
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, { data: currentMessage(message) })
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||
const message =
|
||||
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
|
||||
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, message)
|
||||
return json(route, { data: message })
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (currentMessagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: pageData.items.map(currentMessage).reverse(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
cursors.set(cursor, pageData.cursor)
|
||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
if (url.port === targetPort && targetPort !== appPort)
|
||||
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
|
||||
return route.fallback()
|
||||
})
|
||||
}
|
||||
@@ -522,13 +443,21 @@ function providerConfig(config: MockServerConfig) {
|
||||
|
||||
function currentProviders(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||
return value.all
|
||||
.filter(record)
|
||||
.flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||
: [],
|
||||
)
|
||||
const connected = new Set(
|
||||
Array.isArray(value.connected) ? value.connected.filter((id) => typeof id === "string") : [],
|
||||
)
|
||||
return value.all.filter(record).flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [
|
||||
{
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
package: provider.id,
|
||||
activation: connected.has(provider.id) ? "enabled" : "auto",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function currentModels(value: unknown) {
|
||||
@@ -584,7 +513,7 @@ function currentDefaultModel(value: unknown) {
|
||||
function currentPermission(value: unknown) {
|
||||
const permission = value as Record<string, unknown>
|
||||
if (permission.action) return permission
|
||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
||||
const tool = permission.tool as { messageID?: string; callID?: string; id?: string } | undefined
|
||||
return {
|
||||
id: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
@@ -593,7 +522,9 @@ function currentPermission(value: unknown) {
|
||||
save: permission.always,
|
||||
metadata: permission.metadata,
|
||||
source:
|
||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||
tool?.messageID && (tool.id || tool.callID)
|
||||
? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,202 +565,6 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export function currentMessage(value: unknown): SessionMessageInfo {
|
||||
if (isCurrentMessage(value)) return value
|
||||
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
|
||||
|
||||
const info = value.info
|
||||
const parts = value.parts.filter(record)
|
||||
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
|
||||
throw new Error("Invalid legacy message fixture")
|
||||
|
||||
const time = {
|
||||
created: info.time.created,
|
||||
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
|
||||
}
|
||||
if (info.role === "user") {
|
||||
return {
|
||||
id: info.id,
|
||||
type: "user",
|
||||
time: { created: time.created },
|
||||
text: parts
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n"),
|
||||
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
|
||||
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
|
||||
}
|
||||
}
|
||||
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
|
||||
|
||||
return {
|
||||
id: info.id,
|
||||
type: "assistant",
|
||||
time,
|
||||
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
|
||||
model: {
|
||||
id: typeof info.modelID === "string" ? info.modelID : "model",
|
||||
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
|
||||
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
|
||||
},
|
||||
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
|
||||
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
|
||||
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
|
||||
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
|
||||
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
|
||||
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
|
||||
}
|
||||
|
||||
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
|
||||
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
|
||||
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
|
||||
const source = record(part.source) ? part.source : undefined
|
||||
const sourceText = source && record(source.text) ? source.text : undefined
|
||||
const mention = mentionFrom(sourceText)
|
||||
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
|
||||
return [
|
||||
{
|
||||
data,
|
||||
mime: part.mime,
|
||||
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
|
||||
...(typeof part.filename === "string" ? { name: part.filename } : {}),
|
||||
...(mention ? { mention } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
||||
if (typeof part.name !== "string") return []
|
||||
const mention = mentionFrom(record(part.source) ? part.source : undefined)
|
||||
return [{ name: part.name, ...(mention ? { mention } : {}) }]
|
||||
}
|
||||
|
||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
|
||||
return
|
||||
return { text: value.value, start: value.start, end: value.end }
|
||||
}
|
||||
|
||||
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
|
||||
if (part.type === "text" && typeof part.text === "string")
|
||||
return [
|
||||
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
|
||||
]
|
||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||
const time = record(part.time) ? part.time : undefined
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
|
||||
...(time && typeof time.start === "number"
|
||||
? {
|
||||
time: {
|
||||
created: time.start,
|
||||
...(typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
|
||||
return []
|
||||
|
||||
const state = part.state
|
||||
const time = record(state.time) ? state.time : undefined
|
||||
const toolTime = {
|
||||
created: time && typeof time.start === "number" ? time.start : created,
|
||||
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
|
||||
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
}
|
||||
const input = jsonRecord(state.input) ?? {}
|
||||
const metadata = jsonRecord(state.metadata)
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: typeof part.callID === "string" ? part.callID : part.id,
|
||||
name: part.tool,
|
||||
time: toolTime,
|
||||
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
|
||||
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
|
||||
},
|
||||
]
|
||||
if (state.status === "completed")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
if (state.status === "error")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input,
|
||||
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
|
||||
}
|
||||
|
||||
function structuredError(value: unknown): SessionStructuredError | undefined {
|
||||
if (typeof value === "string") return { type: "Error", message: value }
|
||||
if (!record(value)) return
|
||||
if (typeof value.type === "string" && typeof value.message === "string")
|
||||
return { type: value.type, message: value.message }
|
||||
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
|
||||
return { type: value.name, message: value.data.message }
|
||||
}
|
||||
|
||||
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
|
||||
if (!record(value) || !record(value.cache)) return
|
||||
if (
|
||||
typeof value.input !== "number" ||
|
||||
typeof value.output !== "number" ||
|
||||
typeof value.reasoning !== "number" ||
|
||||
typeof value.cache.read !== "number" ||
|
||||
typeof value.cache.write !== "number"
|
||||
)
|
||||
return
|
||||
return {
|
||||
input: value.input,
|
||||
output: value.output,
|
||||
reasoning: value.reasoning,
|
||||
cache: { read: value.cache.read, write: value.cache.write },
|
||||
}
|
||||
}
|
||||
|
||||
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
|
||||
if (
|
||||
value === "stop" ||
|
||||
value === "length" ||
|
||||
value === "tool-calls" ||
|
||||
value === "content-filter" ||
|
||||
value === "error" ||
|
||||
value === "unknown"
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
|
||||
if (!record(value)) return
|
||||
return Object.fromEntries(
|
||||
@@ -871,18 +606,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
function currentEvent(input: unknown) {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
@@ -27,7 +28,7 @@ export type SseEventOptions = {
|
||||
marker?: string
|
||||
}
|
||||
|
||||
export type SseTransport<T> = {
|
||||
export type SseTransport<T extends OpenCodeEvent> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
@@ -55,7 +56,7 @@ type BrowserTransport = Window & {
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSseTransport<T>(
|
||||
export async function installSseTransport<T extends OpenCodeEvent = OpenCodeEvent>(
|
||||
page: Page,
|
||||
options: { server: string; retry?: number },
|
||||
): Promise<SseTransport<T>> {
|
||||
@@ -93,21 +94,6 @@ export async function installSseTransport<T>(
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const currentEvent = (input: unknown) => {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location:
|
||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
@@ -155,13 +141,14 @@ export async function installSseTransport<T>(
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
})
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
payload: delivery.payload,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||
const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join(""))
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
@@ -266,15 +253,11 @@ export async function installSseTransport<T>(
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
||||
},
|
||||
heartbeat(eventOptions) {
|
||||
const bytes = new TextEncoder().encode(": heartbeat\n\n")
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: [
|
||||
{
|
||||
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
|
||||
options: eventOptions,
|
||||
},
|
||||
],
|
||||
burst: false,
|
||||
type: "raw",
|
||||
bytes: Array.from(bytes),
|
||||
marker: eventOptions?.marker,
|
||||
})
|
||||
},
|
||||
writeRaw(value, cuts, marker) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
||||
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import { stabilityPlan, type VisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
||||
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
||||
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
||||
@@ -25,22 +25,19 @@ export async function markVisualStability(page: Page, label: string) {
|
||||
await markVisualProbe(page, label)
|
||||
}
|
||||
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, stabilityPlan(options))
|
||||
}
|
||||
|
||||
export function analyzeVisualStabilityByMarker(
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
|
||||
export function analyzeVisualStabilityByMarker(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
|
||||
return analyzeVisualTraceByMarker(trace, stabilityPlan(options))
|
||||
}
|
||||
|
||||
export async function expectVisualStability(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
options: VisualStabilityOptions = {},
|
||||
) {
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
@@ -49,6 +46,6 @@ export async function expectVisualStability(
|
||||
...trace,
|
||||
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
||||
},
|
||||
legacyVisualPlan(options),
|
||||
stabilityPlan(options),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type VisualPlan<RegionName extends string = string> = {
|
||||
aggregateMotion?: boolean
|
||||
}
|
||||
|
||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
||||
export type VisualStabilityOptions<RegionName extends string = string> = {
|
||||
flow?: RegionName[]
|
||||
motionTolerance?: number
|
||||
opacityFloor?: number
|
||||
@@ -58,8 +58,8 @@ export function visualPlan<const Regions extends Record<string, VisualRegionDefi
|
||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||
}
|
||||
|
||||
export function legacyVisualPlan<RegionName extends string>(
|
||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
||||
export function stabilityPlan<RegionName extends string>(
|
||||
options: VisualStabilityOptions<RegionName> = {},
|
||||
): VisualPlan<RegionName> {
|
||||
const inferred =
|
||||
options.inferRequired === false
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
var key = "opencode-theme-id"
|
||||
var themeId = localStorage.getItem(key) || "oc-2"
|
||||
|
||||
if (themeId === "oc-1") {
|
||||
themeId = "oc-2"
|
||||
localStorage.setItem(key, themeId)
|
||||
localStorage.removeItem("opencode-theme-css-light")
|
||||
localStorage.removeItem("opencode-theme-css-dark")
|
||||
}
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
+20
-73
@@ -12,7 +12,6 @@ import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchPara
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
ErrorBoundary,
|
||||
@@ -32,16 +31,15 @@ import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
|
||||
import { ServerConnection, ServersProvider } from "@/context/servers"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { LocationProvider } from "@/context/location"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { SessionUIProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { requireServerKey } from "./utils/session-route"
|
||||
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { Home } from "@/pages/home"
|
||||
@@ -49,36 +47,17 @@ import { ServerProvider } from "./context/server"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const DirectoryDraftRedirect = () => {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
|
||||
createEffect(() => {
|
||||
if (search.draftId || !tabs.ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
const conn = createMemo(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
|
||||
)
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -87,14 +66,12 @@ function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={tabs.ready() && <Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -109,13 +86,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<SDKProvider directory={props.draft.directory}>
|
||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ServerProvider>
|
||||
)}
|
||||
@@ -264,8 +241,6 @@ export function AppInterface(props: {
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
startup?: Promise<void>
|
||||
}) {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
@@ -292,7 +267,6 @@ export function AppInterface(props: {
|
||||
<SettingsProvider>
|
||||
<GlobalProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
{/* Proper Routes */}
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route
|
||||
@@ -305,36 +279,9 @@ export function AppInterface(props: {
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
{/* Legacy Routes */}
|
||||
<Route>
|
||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalProvider>
|
||||
</SettingsProvider>
|
||||
</ServersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacySessionRedirect() {
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Navigate
|
||||
href={sessionHref(
|
||||
legacySessionServer(
|
||||
tabs.store.filter((item) => item.type === "session"),
|
||||
params.id,
|
||||
ServerConnection.key(server.list[0]!),
|
||||
),
|
||||
params.id,
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
@@ -258,7 +257,6 @@ function ProviderConnection(props: {
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(() => props.directory)
|
||||
@@ -279,11 +277,7 @@ function ProviderConnection(props: {
|
||||
})
|
||||
const provider = createMemo(() => ({
|
||||
id: props.provider,
|
||||
name:
|
||||
providers.all().get(props.provider)?.name ??
|
||||
serverSync.data.provider.all.get(props.provider)?.name ??
|
||||
controller.integration()?.name ??
|
||||
props.provider,
|
||||
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||
}))
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
|
||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
|
||||
const [form, setForm] = createStore<FormState>({
|
||||
@@ -116,8 +116,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||
// TODO: Restore disabled-provider validation when V2 exposes config reads.
|
||||
disabledProviders: [],
|
||||
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { For, Show, createSignal, startTransition } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { LocationProvider } from "@/context/location"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||
@@ -22,9 +22,9 @@ import "./dialog-edit-project-v2.css"
|
||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
return (
|
||||
<SettingsServerDataScope server={props.server}>
|
||||
<SDKProvider directory={props.project.worktree}>
|
||||
<LocationProvider directory={props.project.worktree}>
|
||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||
</SDKProvider>
|
||||
</LocationProvider>
|
||||
</SettingsServerDataScope>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServer } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
interface ForkableMessage {
|
||||
id: string
|
||||
@@ -25,29 +27,27 @@ function formatTime(date: Date): string {
|
||||
export const DialogFork: Component = () => {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const prompt = usePrompt()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
const messages = createMemo((): ForkableMessage[] => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return []
|
||||
|
||||
const msgs = sync().data.message[sessionID] ?? []
|
||||
const msgs = data.session.message.list(sessionID)
|
||||
const result: ForkableMessage[] = []
|
||||
|
||||
for (const message of msgs) {
|
||||
if (message.role !== "user") continue
|
||||
|
||||
const parts = sync().data.part[message.id] ?? []
|
||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||
if (!textPart) continue
|
||||
if (message.type !== "user" || !message.text) continue
|
||||
|
||||
result.push({
|
||||
id: message.id,
|
||||
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
|
||||
text: message.text.replace(/\n/g, " ").slice(0, 200),
|
||||
time: formatTime(new Date(message.time.created)),
|
||||
})
|
||||
}
|
||||
@@ -60,20 +60,32 @@ export const DialogFork: Component = () => {
|
||||
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const parts = sync().data.part[item.id] ?? []
|
||||
const restored = extractPromptFromParts(parts, {
|
||||
directory: sdk().directory,
|
||||
const message = data.session.message.get(sessionID, item.id)
|
||||
if (message?.type !== "user") return
|
||||
const restored = extractPromptFromMessage(message, {
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk().directory)
|
||||
const dir = base64Encode(location().directory)
|
||||
|
||||
sdk()
|
||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
serverSDK.api.session
|
||||
.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
.then((forked) => {
|
||||
data.session.remember(forked)
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
const target = prompt.capture({ dir, id: forked.id })
|
||||
target.set(restored)
|
||||
target.context.replaceComments(
|
||||
extractPromptComments(message).map((comment) => ({
|
||||
type: "file",
|
||||
path: comment.path,
|
||||
selection: comment.selection,
|
||||
comment: comment.comment,
|
||||
preview: comment.preview,
|
||||
commentOrigin: comment.origin,
|
||||
})),
|
||||
)
|
||||
navigate(sessionHref(server.key, forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
@@ -14,12 +15,13 @@ const statusLabels = {
|
||||
} as const
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync().data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
|
||||
.map((server) => ({ name: server.name, status: server.status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
@@ -47,7 +49,9 @@ export const DialogSelectMcp: Component = () => {
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync().data.mcp[i.name]
|
||||
const mcpStatus = () =>
|
||||
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
|
||||
?.status
|
||||
const status = () => mcpStatus()?.status
|
||||
const statusLabel = () => {
|
||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||
|
||||
@@ -82,7 +82,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.name")}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
value={props.name}
|
||||
defaultValue={props.name}
|
||||
disabled={props.busy}
|
||||
onChange={props.onNameChange}
|
||||
onKeyDown={keyDown}
|
||||
@@ -92,7 +92,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.username")}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
value={props.username}
|
||||
defaultValue={props.username}
|
||||
disabled={props.busy}
|
||||
onChange={props.onUsernameChange}
|
||||
onKeyDown={keyDown}
|
||||
@@ -101,7 +101,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
type="password"
|
||||
label={language.t("dialog.server.add.password")}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
value={props.password}
|
||||
defaultValue={props.password}
|
||||
disabled={props.busy}
|
||||
onChange={props.onPasswordChange}
|
||||
onKeyDown={keyDown}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -15,7 +14,6 @@ type SkillItem = {
|
||||
location: string
|
||||
}
|
||||
|
||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
|
||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
@@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
)
|
||||
|
||||
const ExtensionRow: Component<{
|
||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||
icon: "mcp" | "cube" | "post-skill"
|
||||
name: string
|
||||
status?: string
|
||||
children?: unknown
|
||||
}> = (props) => (
|
||||
<div class="project-settings-extension-row">
|
||||
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{
|
||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||
</div>
|
||||
<Show when={props.status}>
|
||||
{(status) => (
|
||||
<span class="project-settings-extension-row-status">
|
||||
<span class="project-settings-extension-row-status-dot" />
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{props.children as any}
|
||||
</div>
|
||||
)
|
||||
@@ -75,56 +64,55 @@ const SharedSection: Component<{
|
||||
export const ProjectSettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const directorySDK = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sync = useSync()
|
||||
const toggleMcp = useMcpToggle()
|
||||
const directorySDK = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const toggleMcp = useMcpToggle(() => directorySDK().directory)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const ref = { directory: directorySDK().directory }
|
||||
void Promise.all([
|
||||
data.location.mcp.server.sync(),
|
||||
data.location.skill.sync(),
|
||||
data.location.mcp.server.sync(ref),
|
||||
data.location.skill.sync(ref),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
|
||||
const [serverMcp] = createResource(
|
||||
serverSDK,
|
||||
(sdk) =>
|
||||
sdk.api.mcp
|
||||
.list()
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const globalMcpNames = createMemo(() =>
|
||||
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||
[...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(),
|
||||
)
|
||||
const projectMcpNames = createMemo(() => {
|
||||
const shared = new Set(globalMcpNames())
|
||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||
if (configured.length > 0) return configured.sort()
|
||||
return Object.keys(sync().data.mcp ?? {})
|
||||
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
|
||||
.map((server) => server.name)
|
||||
.filter((name) => !shared.has(name))
|
||||
.sort()
|
||||
})
|
||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||
const mcpEnabled = (name: string) =>
|
||||
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
|
||||
?.status.status === "connected"
|
||||
|
||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
||||
const [globalPluginList] = createResource(
|
||||
() => serverSDK.connection.status() === "connected",
|
||||
() => serverSDK.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const [projectPluginList] = createResource(
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const [serverSkills] = createResource(
|
||||
serverSDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const [directorySkills] = createResource(
|
||||
directorySDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill
|
||||
.list({ location: { directory: sdk.directory } })
|
||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
const projectSkills = createMemo(() => {
|
||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||
const shared = new Set(serverSkills().map(skillKey))
|
||||
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
|
||||
(item) => !shared.has(skillKey(item)),
|
||||
)
|
||||
})
|
||||
|
||||
const mcpRows = (items: string[]) => (
|
||||
@@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
@@ -205,31 +193,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="lsps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||
</div>
|
||||
<Show when={sync().data.lsp.length > 0}>
|
||||
<ExtensionCard>
|
||||
<For each={sync().data.lsp}>
|
||||
{(item) => (
|
||||
<ExtensionRow
|
||||
icon="code"
|
||||
name={item.name || item.id}
|
||||
status={
|
||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||
@@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
}
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -228,8 +228,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
const references = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
(data.location.reference.list({ directory: sdk().directory }) ?? [])
|
||||
.filter((reference) => !reference.hidden)
|
||||
.map((reference) => ({
|
||||
id: `reference:${reference.name}`,
|
||||
kind: "reference" as const,
|
||||
@@ -248,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
})),
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
@@ -294,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
})),
|
||||
])
|
||||
const slashCommands = createMemo(() => [
|
||||
...sync().data.command.map((item) => ({
|
||||
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
||||
id: `custom.${item.name}`,
|
||||
trigger: item.name,
|
||||
title: item.name,
|
||||
@@ -354,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
if (item) openComment(item, props, layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
@@ -557,7 +557,6 @@ function PromptInputV2ModelControl(props: {
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
props: PromptInputV2ControllerProps,
|
||||
sync: ReturnType<typeof useSync>,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
@@ -575,9 +574,7 @@ function openComment(
|
||||
})
|
||||
})
|
||||
}
|
||||
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||
const review =
|
||||
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||
const review = item.commentOrigin === "review"
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
@@ -111,93 +109,6 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
@@ -212,12 +123,3 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -16,6 +16,21 @@ export type PromptInputHistory = {
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
export function upgradeHistoryState(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
|
||||
const entries = value.entries
|
||||
if (!Array.isArray(entries)) return value
|
||||
return {
|
||||
...value,
|
||||
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
|
||||
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
|
||||
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
|
||||
return [entry as PromptHistoryStoredEntry]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
@@ -42,11 +57,11 @@ export function createPromptInputHistory(): PromptInputHistory {
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
||||
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell, shellInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
||||
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
|
||||
@@ -3,16 +3,17 @@ import type { Prompt } from "@/context/prompt"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
clonePromptParts,
|
||||
normalizePromptHistoryEntry,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
type PromptHistoryComment,
|
||||
} from "./history"
|
||||
import { upgradeHistoryState } from "./history-store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
const entry = (value: string) => ({ prompt: text(value), comments: [] })
|
||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||
id,
|
||||
path: "src/a.ts",
|
||||
@@ -42,7 +43,7 @@ describe("prompt-input history", () => {
|
||||
})
|
||||
|
||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||
const entries = [text("third"), text("second"), text("first")]
|
||||
const entries = [entry("third"), entry("second"), entry("first")]
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
@@ -95,10 +96,10 @@ describe("prompt-input history", () => {
|
||||
expect(up.entry.comments).toEqual([comment("c1")])
|
||||
})
|
||||
|
||||
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
|
||||
const entry = normalizePromptHistoryEntry(text("legacy"))
|
||||
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
|
||||
expect(entry.comments).toEqual([])
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export type PromptHistoryEntry = {
|
||||
comments: PromptHistoryComment[]
|
||||
}
|
||||
|
||||
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
||||
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
@@ -60,12 +60,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||
}
|
||||
|
||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||
if (Array.isArray(entry)) {
|
||||
return {
|
||||
prompt: clonePromptParts(entry),
|
||||
comments: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
prompt: clonePromptParts(entry.prompt),
|
||||
comments: clonePromptHistoryComments(entry.comments),
|
||||
|
||||
@@ -11,18 +11,6 @@ type SessionCreateInput = {
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}
|
||||
const admitted: Array<{
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
}> = []
|
||||
const confirmed: unknown[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
@@ -36,12 +24,10 @@ const switchedModels: Array<{
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const admittedServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
const navigations: string[] = []
|
||||
let serverSessionSyncs = 0
|
||||
let restoredPrompts = 0
|
||||
let clearEchoCalls = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
@@ -52,15 +38,12 @@ let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let locationFailure: Error | undefined
|
||||
let promptFailure: Error | undefined
|
||||
let clearEchoResult = true
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let activeServer = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const draftServers: Record<string, string> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
@@ -178,7 +161,7 @@ beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useNavigate: () => (href: string) => navigations.push(href),
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
@@ -191,7 +174,10 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/core/util/encode", () => ({
|
||||
base64Decode: (value: string) => value,
|
||||
base64Encode: (value: string) => value,
|
||||
checksum: (value: string) => value,
|
||||
sampledChecksum: (value: string) => value,
|
||||
}))
|
||||
|
||||
mock.module("@/context/local", () => ({
|
||||
@@ -213,13 +199,8 @@ beforeAll(async () => {
|
||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
}))
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
@@ -233,83 +214,35 @@ beforeAll(async () => {
|
||||
usePrompt: () => prompt,
|
||||
}))
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
mock.module("@/context/location", () => ({
|
||||
useWorkspaceLocation: () => {
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
inbox: {
|
||||
echo: (value: {
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
}) => {
|
||||
admittedServers.push(server)
|
||||
admitted.push(value)
|
||||
},
|
||||
confirm: (value: unknown) => {
|
||||
confirmed.push(value)
|
||||
},
|
||||
clearEcho: () => {
|
||||
clearEchoCalls++
|
||||
return clearEchoResult
|
||||
},
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
mock.module("@/context/server-sdk", () => ({
|
||||
useServerSDK: () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
api: rootClient.api,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: activeServer }),
|
||||
useData: () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
},
|
||||
location: {
|
||||
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
|
||||
command: {
|
||||
list: () => commands,
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -330,8 +263,6 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
admitted.length = 0
|
||||
confirmed.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
@@ -340,11 +271,9 @@ beforeEach(() => {
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
syncedServers.length = 0
|
||||
admittedServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
navigations.length = 0
|
||||
restoredPrompts = 0
|
||||
clearEchoCalls = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
@@ -352,8 +281,7 @@ beforeEach(() => {
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
activeServer = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
@@ -363,11 +291,8 @@ beforeEach(() => {
|
||||
worktreeFailure = undefined
|
||||
locationFailure = undefined
|
||||
promptFailure = undefined
|
||||
clearEchoResult = true
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
@@ -414,6 +339,7 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(navigations).toEqual(["/server/server-a/session/session-1"])
|
||||
})
|
||||
|
||||
test("stops when the created workspace cannot initialize", async () => {
|
||||
@@ -429,8 +355,6 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
@@ -442,18 +366,15 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
activeServer = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(admittedServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
@@ -472,15 +393,6 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(admitted[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
})
|
||||
expect(admitted[0]?.messageID).toStartWith("msg_")
|
||||
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
@@ -495,14 +407,19 @@ describe("prompt submit worktree selection", () => {
|
||||
text: "ls",
|
||||
files: [],
|
||||
agents: [],
|
||||
metadata: {
|
||||
displayText: "ls",
|
||||
comments: [],
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
})
|
||||
|
||||
test("keeps a confirmed echo when the prompt response is lost", async () => {
|
||||
test("restores the prompt when sending fails", async () => {
|
||||
params = { id: "session-1" }
|
||||
promptFailure = new Error("connection lost")
|
||||
clearEchoResult = false
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
|
||||
})
|
||||
@@ -510,9 +427,7 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(clearEchoCalls).toBe(1)
|
||||
expect(restoredPrompts).toBe(0)
|
||||
expect(restoredPrompts).toBe(1)
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
@@ -21,6 +20,8 @@ import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
import { useServer } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
const submitting = new Set<string>()
|
||||
|
||||
@@ -35,9 +36,8 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
api: ServerSDK["api"]["session"]
|
||||
data: Data
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
@@ -53,17 +53,20 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||
input.data.session.setStatus(input.draft.sessionID, "running")
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||
input.data.session.setStatus(input.draft.sessionID, "idle")
|
||||
}
|
||||
|
||||
const [head, ...tail] = text.split(" ")
|
||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||
if (
|
||||
cmd &&
|
||||
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
|
||||
) {
|
||||
setBusy()
|
||||
try {
|
||||
const messageID = Identifier.ascending("message")
|
||||
@@ -108,14 +111,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
|
||||
setBusy()
|
||||
input.sync.session.inbox.echo({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
agent: input.draft.agent,
|
||||
model: { ...input.draft.model, variant: input.draft.variant },
|
||||
...request,
|
||||
})
|
||||
|
||||
try {
|
||||
const session = input.session()
|
||||
@@ -137,22 +132,24 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const admitted = await input.api.prompt({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
metadata: {
|
||||
displayText: request.displayText,
|
||||
comments: request.comments,
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
...input.draft.model,
|
||||
...(input.draft.variant ? { variant: input.draft.variant } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
input.sync.session.inbox.confirm(admitted)
|
||||
return true
|
||||
} catch (err) {
|
||||
const failed = input.sync.session.inbox.clearEcho({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
if (!failed) return true
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
@@ -186,9 +183,10 @@ type PromptSubmitInput = {
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = input.prompt
|
||||
@@ -209,13 +207,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
serverSync.session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
return sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (
|
||||
@@ -241,21 +235,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
next[result.index] = info
|
||||
return next
|
||||
}
|
||||
next.splice(result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -288,15 +267,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync
|
||||
const submissionScope = submissionSDK.scope
|
||||
const submissionServerSDK = serverSDK
|
||||
const submissionData = data
|
||||
const submissionScope = submissionServerSDK.scope
|
||||
const submissionServer = server.key
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
@@ -318,14 +297,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await submissionSDK.api.worktree
|
||||
const createdWorktree = await submissionServerSDK.api.worktree
|
||||
.create({
|
||||
projectID: submissionSync.data.project,
|
||||
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
|
||||
strategy: "git",
|
||||
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
|
||||
directory: getDirectory(
|
||||
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
|
||||
),
|
||||
})
|
||||
.then(async (created) => {
|
||||
await submissionSDK.api.location.get({ location: { directory: created.directory } })
|
||||
await submissionServerSDK.api.location.get({ location: { directory: created.directory } })
|
||||
return created
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -342,15 +323,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
const created = await submissionServerSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
@@ -364,7 +341,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
submissionData.session.remember(created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
@@ -376,12 +353,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: submissionServer, sessionId: session.id })
|
||||
else navigate(sessionHref(submissionServer, session.id))
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
{ server: submissionServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -445,7 +422,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
void submissionSDK.api.session
|
||||
void submissionServerSDK.api.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
@@ -464,12 +441,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
const customCommand = submissionData.location.command
|
||||
.list({ directory: sessionDirectory })
|
||||
?.find((command) => command.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void submissionSDK.api.session
|
||||
submissionData.session.setStatus(session.id, "running")
|
||||
void submissionServerSDK.api.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
@@ -485,7 +464,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -503,16 +482,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
clearInput()
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: submissionSDK.api.session,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
api: submissionServerSDK.api.session,
|
||||
data: submissionData,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
}).catch((err) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useData } from "@/context/server"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const queryClient = useQueryClient()
|
||||
const data = useData()
|
||||
const location = () => {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
@@ -118,12 +115,15 @@ export function createProviderConnectionController(options: {
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
const key = directory ? pathKey(directory) : null
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
data.location.model.invalidate(ref)
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
|
||||
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
|
||||
])
|
||||
data.location.integration.sync(ref),
|
||||
data.location.provider.sync(ref),
|
||||
data.location.model.sync(ref),
|
||||
]).catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
@@ -40,11 +39,11 @@ function openSessionContext(args: {
|
||||
}
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -56,8 +55,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
fileBrowser: () => isDesktop() && !!params.id,
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -67,7 +66,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
|
||||
import type { Project } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
||||
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||
const workspaces = () =>
|
||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
const assistant = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("estimateSessionContextBreakdown", () => {
|
||||
test("estimates tokens and keeps remaining tokens as other", () => {
|
||||
const messages = [user("u1"), assistant("a1")]
|
||||
const parts = {
|
||||
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
|
||||
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
|
||||
}
|
||||
|
||||
const output = estimateSessionContextBreakdown({
|
||||
messages,
|
||||
parts,
|
||||
input: 20,
|
||||
systemPrompt: "system prompt",
|
||||
})
|
||||
|
||||
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
|
||||
expect(map.system).toBe(4)
|
||||
expect(map.user).toBe(3)
|
||||
expect(map.assistant).toBe(5)
|
||||
expect(map.other).toBe(8)
|
||||
})
|
||||
|
||||
test("scales segments when estimates exceed input", () => {
|
||||
const messages = [user("u1"), assistant("a1")]
|
||||
const parts = {
|
||||
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
|
||||
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
|
||||
}
|
||||
|
||||
const output = estimateSessionContextBreakdown({
|
||||
messages,
|
||||
parts,
|
||||
input: 10,
|
||||
systemPrompt: "z".repeat(200),
|
||||
})
|
||||
|
||||
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
|
||||
expect(total).toBeLessThanOrEqual(10)
|
||||
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Message, Part } from "@/types"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
export type SessionContextBreakdownSegment = {
|
||||
key: SessionContextBreakdownKey
|
||||
tokens: number
|
||||
width: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||
|
||||
const charsFromUserPart = (part: Part) => {
|
||||
if (part.type === "text") return part.text.length
|
||||
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||
return 0
|
||||
}
|
||||
|
||||
const charsFromAssistantPart = (part: Part) => {
|
||||
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||
|
||||
const input = Object.keys(part.state.input).length * 16
|
||||
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
|
||||
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
|
||||
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
|
||||
return { assistant: 0, tool: input }
|
||||
}
|
||||
|
||||
const build = (
|
||||
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
|
||||
input: number,
|
||||
) => {
|
||||
return [
|
||||
{
|
||||
key: "system",
|
||||
tokens: tokens.system,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
tokens: tokens.user,
|
||||
},
|
||||
{
|
||||
key: "assistant",
|
||||
tokens: tokens.assistant,
|
||||
},
|
||||
{
|
||||
key: "tool",
|
||||
tokens: tokens.tool,
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
tokens: tokens.other,
|
||||
},
|
||||
]
|
||||
.filter((x) => x.tokens > 0)
|
||||
.map((x) => ({
|
||||
key: x.key,
|
||||
tokens: x.tokens,
|
||||
width: toPercent(x.tokens, input),
|
||||
percent: toPercentLabel(x.tokens, input),
|
||||
})) as SessionContextBreakdownSegment[]
|
||||
}
|
||||
|
||||
export function estimateSessionContextBreakdown(args: {
|
||||
messages: Message[]
|
||||
parts: Record<string, Part[] | undefined>
|
||||
input: number
|
||||
systemPrompt?: string
|
||||
}) {
|
||||
if (!args.input) return []
|
||||
|
||||
const counts = args.messages.reduce(
|
||||
(acc, msg) => {
|
||||
const parts = args.parts[msg.id] ?? []
|
||||
if (msg.role === "user") {
|
||||
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
|
||||
return { ...acc, user: acc.user + user }
|
||||
}
|
||||
|
||||
if (msg.role !== "assistant") return acc
|
||||
const assistant = parts.reduce(
|
||||
(sum, part) => {
|
||||
const next = charsFromAssistantPart(part)
|
||||
return {
|
||||
assistant: sum.assistant + next.assistant,
|
||||
tool: sum.tool + next.tool,
|
||||
}
|
||||
},
|
||||
{ assistant: 0, tool: 0 },
|
||||
)
|
||||
return {
|
||||
...acc,
|
||||
assistant: acc.assistant + assistant.assistant,
|
||||
tool: acc.tool + assistant.tool,
|
||||
}
|
||||
},
|
||||
{
|
||||
system: args.systemPrompt?.length ?? 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
tool: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const tokens = {
|
||||
system: estimateTokens(counts.system),
|
||||
user: estimateTokens(counts.user),
|
||||
assistant: estimateTokens(counts.assistant),
|
||||
tool: estimateTokens(counts.tool),
|
||||
}
|
||||
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
|
||||
|
||||
if (estimated <= args.input) {
|
||||
return build({ ...tokens, other: args.input - estimated }, args.input)
|
||||
}
|
||||
|
||||
const scale = args.input / estimated
|
||||
const scaled = {
|
||||
system: Math.floor(tokens.system * scale),
|
||||
user: Math.floor(tokens.user * scale),
|
||||
assistant: Math.floor(tokens.assistant * scale),
|
||||
tool: Math.floor(tokens.tool * scale),
|
||||
}
|
||||
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@/types"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
|
||||
cost: number,
|
||||
providerID = "openai",
|
||||
modelID = "gpt-4.1",
|
||||
) => {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
providerID,
|
||||
modelID,
|
||||
cost,
|
||||
tokens: {
|
||||
input: tokens.input,
|
||||
output: tokens.output,
|
||||
reasoning: tokens.reasoning,
|
||||
cache: {
|
||||
read: tokens.read,
|
||||
write: tokens.write,
|
||||
},
|
||||
},
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
const user = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
cost: 0,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("getSessionContext", () => {
|
||||
test("computes token totals and usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
|
||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||
]
|
||||
const providers = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
"gpt-4.1": {
|
||||
name: "GPT-4.1",
|
||||
limit: { context: 1000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.total).toBe(500)
|
||||
expect(ctx?.input).toBe(300)
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
})
|
||||
|
||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||
const providers = [{ id: "p-1", models: {} }]
|
||||
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.providerLabel).toBe("p-1")
|
||||
expect(ctx?.modelLabel).toBe("m-1")
|
||||
expect(ctx?.limit).toBeUndefined()
|
||||
expect(ctx?.usage).toBeNull()
|
||||
})
|
||||
|
||||
test("recomputes when message array is mutated in place", () => {
|
||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||
const providers = [{ id: "openai", models: {} }]
|
||||
|
||||
const one = getSessionContext(messages, providers)
|
||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||
const two = getSessionContext(messages, providers)
|
||||
|
||||
expect(one?.message.id).toBe("a1")
|
||||
expect(two?.message.id).toBe("a2")
|
||||
})
|
||||
|
||||
test("returns undefined when inputs are undefined", () => {
|
||||
const ctx = getSessionContext(undefined, undefined)
|
||||
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { AssistantMessage, Message } from "@/types"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
name?: string
|
||||
models: Record<string, Model | undefined>
|
||||
}
|
||||
|
||||
type Model = {
|
||||
name?: string
|
||||
limit: {
|
||||
context: number
|
||||
}
|
||||
}
|
||||
|
||||
type Context = {
|
||||
message: AssistantMessage
|
||||
provider?: Provider
|
||||
model?: Model
|
||||
providerLabel: string
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
|
||||
const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
if (tokenTotal(msg) <= 0) continue
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return undefined
|
||||
|
||||
const provider = providers.find((item) => item.id === message.providerID)
|
||||
const model = provider?.models[message.modelID]
|
||||
const limit = model?.limit.context
|
||||
const total = tokenTotal(message)
|
||||
|
||||
return {
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -11,25 +10,16 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||
system: "var(--syntax-info)",
|
||||
user: "var(--syntax-success)",
|
||||
assistant: "var(--syntax-property)",
|
||||
tool: "var(--syntax-warning)",
|
||||
other: "var(--syntax-comment)",
|
||||
}
|
||||
|
||||
function Stat(props: { label: string; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -39,12 +29,11 @@ function Stat(props: { label: string; value: JSX.Element }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
|
||||
function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) {
|
||||
const file = createMemo(() => {
|
||||
const parts = props.getParts(props.message.id)
|
||||
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
||||
const contents = JSON.stringify(props.message, null, 2)
|
||||
return {
|
||||
name: `${props.message.role}-${props.message.id}.json`,
|
||||
name: `${props.message.type}-${props.message.id}.json`,
|
||||
contents,
|
||||
cacheKey: checksum(contents),
|
||||
}
|
||||
@@ -62,8 +51,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
|
||||
}
|
||||
|
||||
function RawMessage(props: {
|
||||
message: Message
|
||||
getParts: (id: string) => Part[]
|
||||
message: SessionMessageInfo
|
||||
onRendered: () => void
|
||||
time: (value: number | undefined) => string
|
||||
}) {
|
||||
@@ -73,7 +61,7 @@ function RawMessage(props: {
|
||||
<Accordion.Trigger>
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="min-w-0 truncate">
|
||||
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
||||
{props.message.type} <span class="text-text-base">• {props.message.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
|
||||
@@ -84,52 +72,35 @@ function RawMessage(props: {
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content class="bg-background-base">
|
||||
<div class="p-3">
|
||||
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
|
||||
<RawMessageContent message={props.message} onRendered={props.onRendered} />
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
() => {
|
||||
const id = params.id
|
||||
if (!id) return emptyMessages
|
||||
return (sync().data.message[id] ?? []) as Message[]
|
||||
return data.session.message.list(id)
|
||||
},
|
||||
emptyMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
const boundary = userMessages().findIndex((message) => message.id === revert)
|
||||
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.intl(), {
|
||||
@@ -138,7 +109,28 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const provider = providers.all().get(message.model.providerID)
|
||||
const model = provider?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
message,
|
||||
tokens: message.tokens,
|
||||
providerLabel: provider?.name ?? message.model.providerID,
|
||||
modelLabel: model?.name ?? message.model.id,
|
||||
limit: model?.limit.context,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -147,8 +139,8 @@ export function SessionContextTab() {
|
||||
|
||||
const counts = createMemo(() => {
|
||||
const all = messages()
|
||||
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||
const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0)
|
||||
const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0)
|
||||
return {
|
||||
all: all.length,
|
||||
user,
|
||||
@@ -157,8 +149,7 @@ export function SessionContextTab() {
|
||||
})
|
||||
|
||||
const systemPrompt = createMemo(() => {
|
||||
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
|
||||
const system = msg?.system
|
||||
const system = messages().findLast((message) => message.type === "system")?.text
|
||||
if (!system) return
|
||||
const trimmed = system.trim()
|
||||
if (!trimmed) return
|
||||
@@ -177,30 +168,6 @@ export function SessionContextTab() {
|
||||
return c.modelLabel
|
||||
})
|
||||
|
||||
const breakdown = createMemo(
|
||||
on(
|
||||
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
||||
() => {
|
||||
const c = ctx()
|
||||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: messages(),
|
||||
parts: sync().data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const breakdownLabel = (key: SessionContextBreakdownKey) => {
|
||||
if (key === "system") return language.t("context.breakdown.system")
|
||||
if (key === "user") return language.t("context.breakdown.user")
|
||||
if (key === "assistant") return language.t("context.breakdown.assistant")
|
||||
if (key === "tool") return language.t("context.breakdown.tool")
|
||||
return language.t("context.breakdown.other")
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||
@@ -210,12 +177,11 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () =>
|
||||
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||
value: () => `${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
@@ -230,7 +196,7 @@ export function SessionContextTab() {
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
api: sdk().api,
|
||||
api: serverSDK.api,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
@@ -252,8 +218,6 @@ export function SessionContextTab() {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
@@ -314,37 +278,6 @@ export function SessionContextTab() {
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={breakdown().length > 0}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
|
||||
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div
|
||||
class="h-full"
|
||||
style={{
|
||||
width: `${segment.width}%`,
|
||||
"background-color": BREAKDOWN_COLOR[segment.key],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||
<div>{breakdownLabel(segment.key)}</div>
|
||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={systemPrompt()}>
|
||||
{(prompt) => (
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -371,9 +304,7 @@ export function SessionContextTab() {
|
||||
</div>
|
||||
<Accordion multiple>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||
)}
|
||||
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -16,28 +16,32 @@ interface NewSessionViewProps {
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
|
||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||
const project = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
return projectID ? data.project.get(projectID) : undefined
|
||||
})
|
||||
const sandboxes = createMemo(() => project()?.sandboxes ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||
const current = createMemo(() => {
|
||||
const selection = props.worktree
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const projectRoot = createMemo(() => project()?.canonical ?? sdk().directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync().project
|
||||
if (!project) return false
|
||||
return sdk().directory !== project.worktree
|
||||
const current = project()
|
||||
if (!current) return false
|
||||
return sdk().directory !== current.canonical
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
if (value === MAIN_WORKTREE) {
|
||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||
const branch = sync().data.vcs?.branch
|
||||
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||
return language.t("session.new.worktree.main")
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
{label(current())}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sync().project}>
|
||||
<Show when={project()}>
|
||||
{(project) => (
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
|
||||
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
||||
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, For, createMemo, createResource } from "solid-js"
|
||||
import { Component, For, createEffect, createMemo, createResource } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -21,35 +22,35 @@ interface PluginRowItem {
|
||||
export const SettingsExtensionsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const [mcpList, { refetch: refetchMcp }] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.mcp.list().then((result) => result.data),
|
||||
)
|
||||
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
|
||||
const mcps = createMemo<McpRowItem[]>(() => {
|
||||
const configMcp = serverSync.data.config.mcp ?? {}
|
||||
return Object.entries(configMcp).map(([name, config]) => ({
|
||||
name,
|
||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||
return (mcpList.latest ?? []).map((server) => ({
|
||||
name: server.name,
|
||||
enabled: server.status.status === "connected",
|
||||
}))
|
||||
})
|
||||
|
||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||
const before = serverSync.data.config.mcp ?? {}
|
||||
const config = before[item.name]
|
||||
if (typeof config !== "object" || config === null) return
|
||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||
serverSync.set("config", "mcp", next)
|
||||
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
|
||||
if (item.enabled === checked || toggleMcp.isPending) return
|
||||
toggleMcp.mutate(item.name)
|
||||
}
|
||||
|
||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||
const raw = serverSync.data.config.plugin ?? []
|
||||
return raw.map((item) => {
|
||||
const name = typeof item === "string" ? item : item[0]
|
||||
return { name }
|
||||
})
|
||||
})
|
||||
const [pluginList] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
|
||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||
initialValue: [],
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
void data.location.skill.sync().catch(() => undefined)
|
||||
})
|
||||
const skills = () => data.location.skill.list() ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
|
||||
const s = server()
|
||||
const id = sessionID()
|
||||
if (!s || !id) return undefined
|
||||
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
||||
return serverCtx()?.data.session.get(id)?.location.directory
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user