Compare commits

..

23 Commits

Author SHA1 Message Date
Aiden Cline 7188d42b99 feat(core): resume subagent sessions (#43172) 2026-08-17 23:43:13 -05:00
Aiden Cline 9391987443 fix(ai): settle pending Anthropic tool calls (#43136) 2026-08-17 22:58:01 -05:00
opencode-agent[bot] 4d79337d42 chore: remove root vitest dependency (#43167)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:47:24 +10:00
opencode-agent[bot] 401a154ec3 fix(app): keep server details editable (#43170)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:32:43 +10:00
Luke Parker 18c25ae406 refactor(app): use one server event model (#43168) 2026-08-18 13:28:47 +10:00
opencode-agent[bot] cd156f9f1c test(tui): update web search label expectation (#43166)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-18 03:22:35 +00:00
Dax Raad b968a314d3 fix(tui): hide empty tab bar 2026-08-17 23:15:09 -04:00
Luke Parker 93cdfafa78 refactor(desktop): extract renderer platform (#43164) 2026-08-18 13:12:39 +10:00
Dax Raad b6a8d99da5 fix(cli): align automatic update methods 2026-08-17 23:09:49 -04:00
Dax Raad 8cacf150db chore: skip node cli publish build 2026-08-17 22:49:48 -04:00
Luke Parker 76e7b556b6 refactor(desktop): modularize main process (#43159) 2026-08-18 12:23:05 +10:00
Luke Parker a888ba4da7 refactor(app): use current session messages (#42766) 2026-08-18 12:17:39 +10:00
opencode-agent[bot] a1eca087d4 fix(tui): streamline generic tool rendering (#43158)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 21:04:07 -05:00
Aiden Cline 5252cefab2 fix(core): exclude native code mode MCP servers (#43157) 2026-08-17 20:54:25 -05:00
opencode-agent[bot] 839343e5de fix(cli): skip missing node distribution (#43156)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-17 21:42:34 -04:00
opencode-agent[bot] 30d778cf5f fix(tui): clarify web search provider labels (#43155)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 20:41:14 -05:00
Luke Parker 616aba7bbd refactor(desktop): establish typed ipc contract (#43150) 2026-08-18 11:31:05 +10:00
Aiden Cline 006a0a4b34 feat(plugin): expose MCP server transforms (#43125) 2026-08-17 20:17:48 -05:00
Kit Langton 3facbe1dd3 fix(core): support older previous-channel databases (#43142) 2026-08-18 00:45:36 +00:00
opencode-agent[bot] 1430a460f1 fix(ui): smooth shimmer loop (#43144)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 10:40:50 +10:00
Dax Raad 1f96af5ea7 docs: document heap snapshot debugging 2026-08-17 20:37:53 -04:00
Dax Raad 15f0fc9e00 chore: disable generate workflow 2026-08-17 20:26:57 -04:00
opencode-agent[bot] cd3133038b chore: generate 2026-08-18 00:19:50 +00:00
211 changed files with 5406 additions and 4597 deletions
-52
View File
@@ -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
+2 -1
View File
@@ -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
+204
View File
@@ -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.
+42 -29
View File
@@ -28,7 +28,6 @@
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10",
},
},
"packages/ai": {
@@ -3232,7 +3231,7 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
@@ -3242,7 +3241,7 @@
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
@@ -3544,7 +3543,7 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
@@ -3948,7 +3947,7 @@
"es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="],
"es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
@@ -5620,7 +5619,7 @@
"tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -6054,6 +6053,8 @@
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
@@ -6070,8 +6071,6 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="],
@@ -6470,8 +6469,6 @@
"@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"@solidjs/start/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
@@ -6542,6 +6539,12 @@
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="],
@@ -6580,14 +6583,10 @@
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
"astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"astro/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
@@ -6878,10 +6877,6 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -6932,11 +6927,15 @@
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"vite-plugin-dynamic-import/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
@@ -7028,6 +7027,8 @@
"@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/node/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7046,6 +7047,8 @@
"@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/node/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7082,6 +7085,8 @@
"@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/vercel/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
"@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
@@ -7098,6 +7103,8 @@
"@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/vercel/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7456,6 +7463,8 @@
"@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@opencode-ai/www/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7474,6 +7483,8 @@
"@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@opencode-ai/www/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7582,6 +7593,8 @@
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -7646,6 +7659,8 @@
"blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"blume/@astrojs/mdx/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
@@ -7676,6 +7691,8 @@
"blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"blume/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7688,6 +7705,8 @@
"blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
"blume/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7824,12 +7843,6 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -7902,6 +7915,8 @@
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
@@ -8784,8 +8799,6 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+1 -2
View File
@@ -121,8 +121,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -957,9 +957,12 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
]
}
const onMessageStop = (state: ParserState): StepResult => {
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
const finished = Lifecycle.finish(lifecycle, events, {
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
@@ -967,8 +970,8 @@ const onMessageStop = (state: ParserState): StepResult => {
usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata,
})
return [{ ...state, lifecycle }, events]
}
return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult
})
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
// even when the provider message is generic or empty.
@@ -992,7 +995,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -955,6 +955,37 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -441,6 +441,15 @@ export function status(type: SessionStatus["type"], attempt = 1) {
})
}
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?: unknown; created?: number } = {},
@@ -9,6 +9,7 @@ import {
setupTimeline,
shell,
status,
stepStarted,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -96,8 +97,10 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(status("busy", 2), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
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)), 100)
@@ -277,8 +277,7 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name:
providers.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 ""
+19 -10
View File
@@ -9,8 +9,7 @@ import { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { extractPromptFromParts } from "@/utils/prompt"
import { normalizeSessionMessages } from "@/utils/session-message"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
@@ -61,13 +60,12 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return
const restored = extractPromptFromParts(
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
{
directory: location().directory,
attachmentName: language.t("common.attachment"),
},
)
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(location().directory)
serverSDK.api.session
@@ -75,7 +73,18 @@ export const DialogFork: Component = () => {
.then((forked) => {
data.session.remember(forked)
dialog.close()
prompt.set(restored, undefined, { dir, id: 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) => {
@@ -50,7 +50,8 @@ export const DialogSelectMcp: Component = () => {
>
{(i) => {
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
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}
@@ -90,8 +90,8 @@ export const ProjectSettingsExtensions: Component = () => {
.sort()
})
const mcpEnabled = (name: string) =>
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
.status === "connected"
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
?.status.status === "connected"
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
@@ -196,7 +196,6 @@ export const ProjectSettingsExtensions: Component = () => {
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
@@ -407,6 +407,12 @@ 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_")
})
@@ -63,7 +63,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((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")
@@ -135,6 +138,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
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 } : {}),
},
},
})
return true
} catch (err) {
@@ -197,8 +209,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (!sessionID) return Promise.resolve()
input.onAbort?.()
return serverSDK.api.session.interrupt({ sessionID })
.catch(() => {})
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
}
const restoreCommentItems = (
@@ -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)
}
@@ -181,8 +181,7 @@ export function SessionContextTab() {
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
value: () =>
`${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.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()) },
@@ -305,9 +304,7 @@ export function SessionContextTab() {
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => (
<RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />
)}
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
</For>
</Accordion>
</div>
@@ -27,15 +27,16 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
const mcpNames = createMemo(() =>
mcp()
.map((server) => server.name)
.sort((a, b) => a.localeCompare(b)),
)
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) =>
serverSDK.api.plugin
.list({ location: { directory } })
.then((result) => result.data),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length)
+10 -12
View File
@@ -78,16 +78,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scope,
normalizeDir: path.normalizeDir,
list: (dir) =>
serverSDK.api.file
.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
serverSDK.api.file.list({ path: dir, location: { directory: scope() } }).then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
onError: (message) => {
showToast({
variant: "error",
@@ -227,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
},
)
const stop = sdk().event.listen((e) => {
invalidateFromWatcher(e.details, {
const stop = sdk().event.on("filesystem.changed", (event) => {
invalidateFromWatcher(event, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
+64 -114
View File
@@ -1,27 +1,28 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { invalidateFromWatcher } from "./watcher"
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
id: `evt_${file}`,
created: 1,
type: "filesystem.changed",
data: { file, event },
})
describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => {
const loads: string[] = []
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
},
},
{
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), {
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
})
expect(loads).toEqual(["src/new.ts"])
expect(refresh).toEqual(["src"])
@@ -30,30 +31,21 @@ describe("file watcher invalidation", () => {
test("reloads files that are open in tabs", () => {
const loads: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
},
)
invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), {
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
})
expect(loads).toEqual(["src/open.ts"])
})
@@ -61,47 +53,29 @@ describe("file watcher invalidation", () => {
test("refreshes only changed loaded directory nodes", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src", "change"), {
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
})
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), {
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
})
expect(refresh).toEqual(["src"])
})
@@ -109,40 +83,16 @@ describe("file watcher invalidation", () => {
test("ignores invalid or git watcher updates", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: ".git/index.lock",
event: "change",
},
invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), {
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
{
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(
{
type: "project.updated",
properties: {},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
})
expect(refresh).toEqual([])
})
+7 -25
View File
@@ -1,9 +1,7 @@
import type { FileNode } from "@/types"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
type WatcherEvent = {
type: string
properties: unknown
}
type WatcherEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
type WatcherOps = {
normalize: (input: string) => string
@@ -16,15 +14,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
if (!rawPath) return
if (!kind) return
const path = ops.normalize(rawPath)
const path = ops.normalize(event.data.file)
if (!path) return
if (path.startsWith(".git/")) return
@@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
ops.loadFile(path)
}
if (kind === "change") {
const dir = (() => {
if (path === "") return ""
const node = ops.node(path)
if (node?.type !== "directory") return
return path
})()
if (dir === undefined) return
if (!ops.isDirLoaded(dir)) return
ops.refreshDir(dir)
if (event.data.event === "change") {
if (ops.node(path)?.type !== "directory") return
if (!ops.isDirLoaded(path)) return
ops.refreshDir(path)
return
}
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!ops.isDirLoaded(parent)) return
@@ -86,5 +86,4 @@ describe("query keys", () => {
{ id: "b", sandboxes: [] },
])
})
})
@@ -233,7 +233,10 @@ export function createChildStoreManager(input: {
},
get mcp() {
return Object.fromEntries(
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [
server.name,
server.status,
]),
)
},
get mcp_resource() {
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Project } from "@/types"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
describe("applyGlobalEvent", () => {
test("upserts project.updated in sorted position", () => {
const projects = [{ id: "b", worktree: "/b" }] as Project[]
let next = projects
applyGlobalEvent({
event: { type: "project.updated", properties: { id: "a", worktree: "/a" } },
project: projects,
setGlobalProject: (value) => {
next = typeof value === "function" ? value(next) : value
},
refresh() {},
})
expect(next.map((project) => project.id)).toEqual(["a", "b"])
})
test("refreshes on global disposal", () => {
let refreshed = false
applyGlobalEvent({
event: { type: "global.disposed" },
project: [],
setGlobalProject() {},
refresh: () => (refreshed = true),
})
expect(refreshed).toBe(true)
})
})
describe("applyDirectoryEvent", () => {
test("updates vcs and routes refresh events", () => {
const [store, setStore] = createStore({ vcs: { branch: "old" } } as State)
const pushed: string[] = []
let lsp = 0
let references = 0
const apply = (type: string, properties?: unknown) =>
applyDirectoryEvent({
event: { type, properties },
store,
setStore,
directory: "/repo",
push: (directory) => pushed.push(directory),
loadLsp: () => lsp++,
loadReferences: () => references++,
})
apply("vcs.branch.updated", { branch: "main" })
apply("server.instance.disposed")
apply("lsp.updated")
apply("reference.updated")
expect(store.vcs?.branch).toBe("main")
expect(pushed).toEqual(["/repo"])
expect(lsp).toBe(1)
expect(references).toBe(1)
})
})
@@ -1,63 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { produce, type SetStoreFunction, type Store } from "solid-js/store"
import type { Project } from "@/types"
import type { State, VcsCache } from "./types"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (project) => project.id)
if (result.found) {
input.setGlobalProject(
produce((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
}),
)
return
}
input.setGlobalProject(
produce((draft) => {
draft.splice(result.index, 0, properties)
}),
)
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
directory: string
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
}) {
switch (input.event.type) {
case "server.instance.disposed":
input.push(input.directory)
break
case "vcs.branch.updated": {
const properties = input.event.properties as { branch?: string }
if (input.store.vcs?.branch === properties.branch) break
const next = { ...input.store.vcs, branch: properties.branch }
input.setStore("vcs", next)
input.vcsCache?.setStore("value", next)
break
}
case "lsp.updated":
input.loadLsp()
break
case "reference.updated":
input.loadReferences?.()
break
}
}
@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
AgentListOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
@@ -1,8 +1,4 @@
import type {
AgentListOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+4 -1
View File
@@ -102,7 +102,10 @@ function createServerController(
const sdk = createServerSdkContext(conn, scope)
const data = createData({
api: () => sdk.api,
event: sdk.event,
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
},
connection: sdk.connection,
directory: "",
})
+2 -7
View File
@@ -13,10 +13,7 @@ export type WorkspaceLocation = LocationContext & {
const context = createSimpleContext({
name: "Location",
init: (props: {
directory: string | Accessor<string>
workspaceID?: string | Accessor<string | undefined>
}) => {
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const data = useData()
const ref = createMemo(() => ({
@@ -44,9 +41,7 @@ const context = createSimpleContext({
})
})
const location = createMemo(() =>
serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory),
)
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
return createMemo<WorkspaceLocation>(() => ({
...location(),
ref: ref(),
+2 -1
View File
@@ -26,7 +26,8 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
+11 -14
View File
@@ -3,12 +3,12 @@ import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, o
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { Data } from "@opencode-ai/client/solid"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import type { EventSessionError } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
@@ -32,7 +32,7 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
error: EventSessionError["properties"]["error"]
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -216,8 +216,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
const sessionID = event.properties.sessionID
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
@@ -246,10 +245,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
directory: string,
event: { properties: EventSessionError["properties"] },
sessionID: string,
error: ErrorNotification["error"],
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
@@ -258,27 +257,25 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
void playSoundById(settings.sounds.errors())
}
const error = event.properties.error
append({
directory,
time,
viewed: viewedInCurrentSession(sessionID),
type: "error",
session: sessionID ?? "global",
session: sessionID,
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionHref(input.key, sessionID ?? "global")
const href = sessionHref(input.key, sessionID)
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
}
})
}
const unsub = input.sdk.eventByDir.listen((e) => {
const event = e.details
const unsub = input.sdk.event.listen((event) => {
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
@@ -286,14 +283,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
)
return
const directory = event.current?.location?.directory
const directory = event.location?.directory
if (!directory) return
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
handleSessionError(directory, event.data.sessionID, event.data.error, time)
return
}
handleSessionIdle(directory, event, time)
handleSessionIdle(directory, event.data.sessionID, time)
})
onCleanup(() => {
meta.disposed = true
+3 -11
View File
@@ -54,8 +54,6 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
@@ -204,20 +202,14 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
return next
}
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, event.current?.location?.directory)
}
const unsubscribe = input.sdk.eventByDir.listen((event) => {
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
handlePermission(event)
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
+13 -1
View File
@@ -1,5 +1,4 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@/types"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
@@ -14,6 +13,19 @@ interface PartBase {
end: number
}
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
+79 -26
View File
@@ -1,33 +1,86 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent } from "./server-sdk"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./server-sdk"
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.asked",
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} as OpenCodeEvent
const permission = {
id: "evt_permission",
created: 1,
type: "permission.asked",
location: { directory: "/repo", workspaceID: "workspace_1" },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
expect(adaptServerEvent(current)).toMatchObject({
id: "evt_1",
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
current,
function setup() {
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
}
describe("server event stream", () => {
test("publishes the original current event with exact data", () => {
const server = setup()
const received: OpenCodeEvent[] = []
let requestID: string | undefined
server.event.on("permission.asked", (event) => {
requestID = event.data.id
})
server.event.listen((event) => received.push(event))
server.publish(permission)
expect(requestID).toBe("perm_1")
expect(received).toEqual([permission])
expect(received[0]).toBe(permission)
server.dispose()
})
test("filters locations without changing workspace identity", () => {
const server = setup()
const repo: OpenCodeEvent[] = []
const other: OpenCodeEvent[] = []
const all: OpenCodeEvent[] = []
let workspaceID: string | undefined
const global = {
id: "evt_connected",
type: "server.connected",
data: {},
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
const repoEvents = server.event.location("/repo")
repoEvents.on("permission.asked", (event) => {
workspaceID = event.location?.workspaceID
})
repoEvents.listen((event) => repo.push(event))
server.event.location("/other").listen((event) => other.push(event))
server.event.listen((event) => all.push(event))
server.publish(permission)
server.publish(global)
expect(repo).toEqual([permission])
expect(workspaceID).toBe("workspace_1")
expect(other).toEqual([])
expect(all).toEqual([permission, global])
server.dispose()
})
test("isolates servers and clears subscriptions with their owner", () => {
const first = setup()
const second = setup()
const received = { first: 0, second: 0 }
first.event.listen(() => received.first++)
second.event.listen(() => received.second++)
first.publish(permission)
first.dispose()
first.publish(permission)
second.publish(permission)
expect(received).toEqual({ first: 1, second: 1 })
second.dispose()
})
})
+49 -45
View File
@@ -1,6 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import type { Event } from "@/types"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
@@ -10,15 +9,52 @@ import { createRefCountMap } from "@/utils/refcount"
import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
export type OpenCodeEventStream = {
on<Type extends OpenCodeEvent["type"]>(type: Type, handler: (event: OpenCodeEventMap[Type]) => void): VoidFunction
listen(handler: (event: OpenCodeEvent) => void): VoidFunction
}
type OpenCodeEventSource = OpenCodeEventStream & {
location(directory: string): OpenCodeEventStream
}
export function createOpenCodeEventSource() {
const emitter = createGlobalEmitter<OpenCodeEventMap>()
function stream(directory?: string): OpenCodeEventStream {
return {
on(type, handler) {
return emitter.on(type, (event) => {
if (directory !== undefined && event.location?.directory !== directory) return
handler(event)
})
},
listen(handler) {
return emitter.listen((event) => {
if (directory !== undefined && event.details.location?.directory !== directory) return
handler(event.details)
})
},
}
}
const event: OpenCodeEventSource = {
...stream(),
location: (directory) => stream(directory),
}
onCleanup(() => emitter.clear())
return {
event,
publish(event: OpenCodeEvent) {
emitter.emit(event.type, event)
},
}
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
export type ServerConnectionStatus = ClientConnectionStatus
type ServerSDKBase = {
server: ServerConnection.Any
@@ -30,28 +66,19 @@ type ServerSDKBase = {
attempt: Accessor<number>
error: Accessor<string | undefined>
}
eventByDir: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
}
event: {
on: CurrentEventEmitter["on"]
listen: CurrentEventEmitter["listen"]
}
event: OpenCodeEventSource
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
const emitter = createGlobalEmitter<CurrentEventMap>()
const events = createOpenCodeEventSource()
const connection = createClientConnection(api, {
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
emitter.emit(event.type, event)
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
events.publish(event)
},
log: {
info(message, data) {
@@ -61,25 +88,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
},
})
onCleanup(() => {
dirEmitter.clear()
emitter.clear()
})
return {
server,
scope,
url: server.http.url,
api,
connection,
eventByDir: {
on: dirEmitter.on.bind(dirEmitter),
listen: dirEmitter.listen.bind(dirEmitter),
},
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
},
event: events.event,
}
}
@@ -99,25 +114,14 @@ export const useServerSDK = () => {
return server.ctx.sdk
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type LocationContext = {
directory: string
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
event: OpenCodeEventStream
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.eventByDir.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
event: emitter,
event: serverSDK.event.location(directory),
}
}
+12 -50
View File
@@ -5,14 +5,8 @@ import { getOwner, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { type ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
loadGlobalConfigQuery,
loadPathQuery,
} from "./global-sync/bootstrap"
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@@ -224,55 +218,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return promise
}
const unsub = serverSDK.eventByDir.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
connection.handleEvent({ type: eventType })
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => void bootstrap.refetch(),
setGlobalProject: setProjects,
})
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
return
}
const existing = children.children[key]
if (!existing) return
const directory = event.location.directory
const key = directoryKey(directory)
if (!children.children[key]) return
children.mark(key)
if (
eventType === "config.updated" ||
eventType === "agent.updated"
)
queue.push(key)
const [store, setStore] = existing
if (eventType === "worktree.updated") void bootstrap.refetch()
if (eventType !== "vcs.branch.updated")
applyDirectoryEvent({
event,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void data.location.reference.sync({ directory: key }).catch(() => undefined)
},
})
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
-108
View File
@@ -1,108 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
}
delete draft.part[input.messageID]
}
+2 -2
View File
@@ -215,8 +215,8 @@ function createWorkspaceTerminalSession(
})
}
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
const unsub = sdk.event.on("pty.exited", (event) => {
removeExited(event.data.id)
})
onCleanup(unsub)
+2 -4
View File
@@ -21,7 +21,7 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const legacyData = createMemo(() => ({
const sessionUIData = createMemo(() => ({
session: data.session.list(),
session_status: Object.fromEntries(
data.session
@@ -32,13 +32,11 @@ export function SessionUIProvider(
]),
),
session_diff: {},
message: {},
part: {},
}))
return (
<DataProvider
data={legacyData()}
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
onNavigateToSession={navigateToSession}
@@ -57,17 +57,16 @@ export function createHomeSessionsController(home: HomeController) {
? homeSessionIndexKey(ServerConnection.key(conn))
: (["home", "session-index", "unselected"] as const),
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
queryFn:
ctx
? async ({ signal }) => {
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
signal,
)
index.forEach(ctx.data.session.remember)
return Date.now()
}
: skipToken,
queryFn: ctx
? async ({ signal }) => {
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
signal,
)
index.forEach(ctx.data.session.remember)
return Date.now()
}
: skipToken,
retry: false,
staleTime: 30_000,
refetchOnMount: true,
@@ -15,9 +15,14 @@ export function useSessionTabAvatarState(
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
return !!sessionPermissionRequest(
ctx.data.session.list(),
ctx.data.session.permission.list,
sessionId(),
(item) => {
return !permission.autoResponds(item, directory())
},
)
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
@@ -88,7 +88,9 @@ export function createNewSessionWorkspaceController(input: {
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
createEffect(() => {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(() => undefined)
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
+28 -13
View File
@@ -1,5 +1,5 @@
import type { FilePart, UserMessage } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FilePart } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -92,7 +92,6 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route"
@@ -439,11 +438,29 @@ export default function Page() {
createEffect(
on(
() => lastUserMessage()?.id,
() => [lastUserMessage(), controller.data.info()] as const,
() => {
const msg = lastUserMessage()
if (!msg) return
syncSessionModel(local, msg)
const message = lastUserMessage()
const info = controller.data.info()
const metadata = message?.metadata
const agent = typeof metadata?.agent === "string" ? metadata.agent : info?.agent
const model = metadata?.model
const selected =
model &&
typeof model === "object" &&
!Array.isArray(model) &&
typeof model.providerID === "string" &&
typeof model.modelID === "string"
? {
providerID: model.providerID,
modelID: model.modelID,
variant: typeof model.variant === "string" ? model.variant : undefined,
}
: info?.model
? { providerID: info.model.providerID, modelID: info.model.id, variant: info.model.variant }
: undefined
if (!info || !agent || !selected) return
syncSessionModel(local, { sessionID: info.id, agent, model: selected })
},
),
)
@@ -520,8 +537,6 @@ export default function Page() {
return open
}, desktopReviewOpen())
// TODO: Restore turn diffs when current transcript projections expose message summaries.
const turnDiffs = createMemo(() => list(undefined))
const nogit = createMemo(() => {
const current = project()
return !!current && current.vcs !== "git"
@@ -539,7 +554,6 @@ export default function Page() {
) {
list.push("branch")
}
list.push("turn")
return list
})
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
@@ -602,7 +616,7 @@ export default function Page() {
}, 100)
onCleanup(
sdk().event.listen((event) => {
if (event.details.type === "filesystem.changed") refreshVcs()
if (event.type === "filesystem.changed") refreshVcs()
}),
)
createEffect(
@@ -619,7 +633,8 @@ export default function Page() {
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
// TODO: Restore turn diffs when the V2 transcript exposes snapshot diffs.
return []
}
const activeReviewFile = () => {
const diffs = reviewDiffs()
@@ -685,7 +700,7 @@ export default function Page() {
return "main"
})
const setActiveMessage = (message: UserMessage | undefined) => {
const setActiveMessage = (message: SessionMessageUser | undefined) => {
messageMark = scrollMark
setStore("messageId", message?.id)
}
@@ -40,7 +40,9 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
loading:
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
session: {
id: input.sessionID(),
@@ -72,9 +72,7 @@ export function SessionComposerRegion(props: {
</div>
)}
</Show>
<div
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
@@ -53,9 +53,9 @@ export function createSessionComposerController() {
if (!primary()) return []
const id = params.id
if (!id) return []
const assistant = data.session.message.list(id).findLast(
(message) => message.type === "assistant" && message.time.completed === undefined,
)
const assistant = data.session.message
.list(id)
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
if (assistant?.type !== "assistant") return []
return assistant.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
@@ -149,14 +149,12 @@ export function createSessionComposerController() {
if (!primary()) return
const sessionID = params.id
if (!sessionID) return
await serverSDK.api.session
.background({ sessionID })
.catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
await serverSDK.api.session.background({ sessionID }).catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
}
const [store, setStore] = createStore({
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@/types"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createRoot, createSignal } from "solid-js"
import {
normalizeSessionTab,
@@ -9,26 +9,20 @@ import {
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
const user = (id: string): UserMessage => ({
const user = (id: string): SessionMessageUser => ({
id,
sessionID: "session",
role: "user",
type: "user",
text: id,
time: { created: 0 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
const assistant: AssistantMessage = {
const assistant: SessionMessageAssistant = {
id: "msg_2",
sessionID: "session",
role: "assistant",
type: "assistant",
time: { created: 0 },
parentID: "msg_1",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
model: { id: "model", providerID: "provider" },
content: [],
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
@@ -45,11 +39,12 @@ describe("session controller invariants", () => {
})
test("selects user history strictly before the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant, user("msg_b"), user("msg_c")]
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
@@ -1,9 +1,8 @@
import type { Message, UserMessage } from "@/types"
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file"
import { useData } from "@/context/server"
import { same } from "@/utils/same"
import { normalizeSessionMessages } from "@/utils/session-message"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -14,8 +13,8 @@ import {
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
@@ -40,12 +39,10 @@ export function createSessionController(input: {
const id = sessionID()
return id && data.session.status(id) === "running" ? { type: "busy" as const } : idle
})
const transcript = createMemo(() => {
const messages = createMemo(() => {
const id = sessionID()
return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined
return id ? data.session.message.list(id) : emptyMessages
})
const messages = createMemo(() => transcript()?.messages ?? emptyMessages)
const parts = (messageID: string) => transcript()?.parts.get(messageID) ?? []
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const visibleUserMessages = createMemo(
@@ -84,7 +81,6 @@ export function createSessionController(input: {
},
history: {
messages,
parts,
userMessages,
visibleUserMessages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
@@ -1,4 +1,4 @@
import type { Message, UserMessage } from "@/types"
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
if (!tab.startsWith("file://")) return tab
@@ -9,12 +9,11 @@ export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) =>
return [...new Set(tabs.map(normalize))]
}
export function selectSessionUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
export function selectSessionUserMessages(messages: SessionMessageInfo[]) {
return messages.filter((message): message is SessionMessageUser => message.type === "user")
}
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
export function selectVisibleSessionUserMessages(messages: SessionMessageUser[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
return messages.filter((message) => message.id < revertMessageID)
}
@@ -1,16 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@/types"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
({
id: "msg",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
}) as UserMessage
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
})
describe("syncSessionModel", () => {
test("restores the last message through session state", () => {
@@ -1,9 +1,11 @@
import type { UserMessage } from "@/types"
type Local = {
session: {
reset(): void
restore(msg: UserMessage): void
restore(msg: {
sessionID: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
}): void
}
}
@@ -29,7 +31,10 @@ export const resetSessionModel = (local: Local) => {
local.session.reset()
}
export const syncSessionModel = (local: Local, msg: UserMessage) => {
export const syncSessionModel = (
local: Local,
msg: { sessionID: string; agent: string; model: { providerID: string; modelID: string; variant?: string } },
) => {
local.session.restore(msg)
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { visibleTimelineMessages } from "./controller-projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 2 },
},
{ id: "msg_3", type: "user", text: "queued", time: { created: 3 } },
{ id: "msg_4", type: "user", text: "reverted", time: { created: 4 } },
] satisfies SessionMessageInfo[]
describe("visibleTimelineMessages", () => {
test("hides queued inputs until delivery", () => {
const pending = [
{
id: "msg_3",
sessionID: "ses_1",
timeCreated: 3,
type: "user",
delivery: "queue",
payload: { text: "queued" },
},
] satisfies SessionInboxInfo[]
expect(visibleTimelineMessages(messages, pending).map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_4"])
})
test("hides the staged revert boundary and later messages", () => {
expect(visibleTimelineMessages(messages, [], "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_3",
])
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
})
})
@@ -1,3 +1,17 @@
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
export function visibleTimelineMessages(
messages: SessionMessageInfo[],
pending: SessionInboxInfo[],
revertMessageID?: string,
) {
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
if (queued.size === 0 && !revertMessageID) return messages
return messages.filter((message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID))
}
export function timelineChildTitle(input: {
parentID?: string
taskDescription?: string
@@ -1,8 +1,8 @@
import type { Message, Part, UserMessage } from "@/types"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import { createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -17,17 +17,22 @@ import { sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
import { normalizeSessionMessages } from "@/utils/session-message"
const emptyMessages: Message[] = []
const taskDescription = (part: Part, sessionID: string): string | undefined => {
if (part.type !== "tool" || part.tool !== "task") return undefined
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return undefined
const value = part.state.input?.description
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
if (message.type !== "assistant") return
const tool = message.content.findLast((item) => {
if (item.type !== "tool" || (item.name !== "task" && item.name !== "subagent")) return false
const metadata =
item.state.status === "running" || item.state.status === "completed" ? item.state.metadata : undefined
return metadata?.sessionId === sessionID || metadata?.sessionID === sessionID
})
if (tool?.type !== "tool") return
const input = typeof tool.state.input === "string" ? undefined : tool.state.input
const value = input?.description
if (typeof value === "string" && value) return value
return undefined
}
@@ -35,13 +40,10 @@ const taskDescription = (part: Part, sessionID: string): string | undefined => {
export type TimelineSessionSource = {
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages" | "parts">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
userMessages: Accessor<UserMessage[]>
}) {
export function createTimelineController(input: { session: TimelineSessionSource }) {
const navigate = useNavigate()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -54,36 +56,28 @@ export function createTimelineController(input: {
const platform = usePlatform()
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return []
const visible = new Set(input.userMessages().map((message) => message.id))
const boundary = input.session.history
.messages()
.find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = data.session.message.list(id)
if (!boundary) return projected
const index = projected.findIndex((message) => message.id === boundary)
return index < 0 ? projected : projected.slice(0, index)
return visibleTimelineMessages(
input.session.history.messages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const shareUrl = (): string | undefined => undefined
const shareEnabled = () => false
const parentTranscript = createMemo(() => {
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined
return id ? data.session.message.list(id) : emptyMessages
})
const parentMessages = createMemo(() => parentTranscript()?.messages ?? emptyMessages)
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const parts = input.session.history.parts
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return undefined
return parentMessages()
.flatMap((message) => parentTranscript()?.parts.get(message.id) ?? [])
.map((item) => taskDescription(item, id))
.map((message) => taskDescription(message, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
@@ -96,10 +90,7 @@ export function createTimelineController(input: {
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const projection = createTimelineProjection({
messages: input.session.history.messages,
userMessages: input.userMessages,
sessionMessages: projectedMessages,
parts,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
})
@@ -238,8 +229,6 @@ export function createTimelineController(input: {
parentTitle,
childTitle,
showHeader,
parts,
part,
projection,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
@@ -0,0 +1,121 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import {
ContextToolGroup,
Message,
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import {
presentAssistantMessage,
presentAssistantContent,
presentUserMessage,
presentUserParts,
} from "@/utils/session-message"
import { createMemo, Show } from "solid-js"
export function CurrentUserMessage(props: {
sessionID: string
message: SessionMessageUser
agent: string
model: { id: string; providerID: string; variant?: string }
actions?: UserActions
useV2Actions?: boolean
comments?: { path: string; comment: string; selection?: { startLine: number; endLine: number } }[]
}) {
const message = createMemo(() => presentUserMessage(props.sessionID, props.message, props.agent, props.model))
const parts = createMemo(() => presentUserParts(props.sessionID, props.message))
return (
<Message
message={message()}
parts={parts()}
actions={props.actions}
useV2Actions={props.useV2Actions}
comments={props.comments}
/>
)
}
export function CurrentAssistantContent(props: {
sessionID: string
parentID: string
message: SessionMessageAssistant
content: SessionMessageAssistant["content"][number]
contentID: string
showAssistantCopyPartID?: string | null
turnDurationMs?: number
useV2Actions?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
onContentRendered?: () => void
}) {
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
const part = createMemo(() =>
presentAssistantContent(props.sessionID, props.message, props.contentID, props.content),
)
return (
<Show when={part()}>
{(part) => (
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
useV2Actions={props.useV2Actions}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent
virtualizeDiff={false}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
export function CurrentContextToolGroup(props: {
sessionID: string
tools: { message: SessionMessageAssistant; content: SessionMessageAssistantTool; contentID: string }[]
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
onSizeChange?: () => void
}) {
const parts = createMemo(() =>
props.tools.flatMap(({ message, content, contentID }): ToolPart[] => {
const part = presentAssistantContent(props.sessionID, message, contentID, content)
return part?.type === "tool" ? [part] : []
}),
)
return (
<ContextToolGroup
parts={parts()}
open={props.open}
onOpenChange={props.onOpenChange}
busy={props.busy}
onSizeChange={props.onSizeChange}
/>
)
}
export function currentPartDefaultOpen(
sessionID: string,
message: SessionMessageAssistant,
content: SessionMessageAssistant["content"][number],
contentID: string,
shellExpanded: boolean,
editExpanded: boolean,
) {
return partDefaultOpen(
presentAssistantContent(sessionID, message, contentID, content),
shellExpanded,
editExpanded,
)
}
@@ -11,20 +11,10 @@ import {
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Card } from "@opencode-ai/ui/card"
import {
ContextToolGroup,
Message,
MessageDivider,
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import { MessageDivider, SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -34,14 +24,11 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { Project } from "@/types"
import { getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useLanguage } from "@/context/language"
@@ -49,17 +36,22 @@ import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { MessageComment, Timeline, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/utils/workspace"
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import {
CurrentAssistantContent,
CurrentContextToolGroup,
CurrentUserMessage,
currentPartDefaultOpen,
} from "./current-message"
const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
const emptyAssistantMessages: SessionMessageAssistant[] = []
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
@@ -111,89 +103,6 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
)
}
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
const language = useLanguage()
const maxFiles = 10
const [state, setState] = createStore({
showAll: false,
expanded: [] as string[],
})
const showAll = () => state.showAll
const expanded = () => state.expanded
const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles))
const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles)))
return (
<div
data-slot="session-turn-diffs"
data-component="session-turn-diffs-group"
data-show-all={showAll() || undefined}
>
<div data-slot="session-turn-diffs-header">
<span data-slot="session-turn-diffs-label">
{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}
</span>
<DiffChanges changes={props.diffs} />
<Show when={overflow() > 0}>
<span data-slot="session-turn-diffs-toggle" onClick={() => setState("showAll", !showAll())}>
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
</span>
</Show>
{props.action}
</div>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "44px" }}
value={expanded()}
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={visible()}>
{(diff) => {
const opened = createMemo(() => expanded().includes(diff.file))
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={opened()}>
<TimelineDiffView diff={diff} />
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
<Show when={!showAll() && overflow() > 0}>
<div data-slot="session-turn-diffs-more" onClick={() => setState("showAll", true)}>
{language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
</div>
</Show>
</div>
</div>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
@@ -353,17 +262,6 @@ function SessionSummaryPanel(props: {
)
}
function TimelineDiffView(props: { diff: SummaryDiff }) {
const fileComponent = useFileComponent()
const view = normalize(props.diff)
return (
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
</div>
)
}
type MessageTimelineProps = {
session: TimelineSessionSource
actions?: UserActions
@@ -380,7 +278,7 @@ type MessageTimelineProps = {
shouldAnchorBottom: boolean
centered: boolean
setContentRef: (el: HTMLDivElement) => void
userMessages: UserMessage[]
userMessages: SessionMessageUser[]
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
onReview: () => void
workspaceMoveEligible: boolean
@@ -392,7 +290,7 @@ type MessageTimelineProps = {
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
const controller = createTimelineController({ session: props.session })
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
@@ -425,8 +323,6 @@ function MessageTimelineView(
const parentID = props.data.parentID
const parentTitle = props.data.parentTitle
const childTitle = props.data.childTitle
const getMsgParts = props.data.parts
const getMsgPart = props.data.part
const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
const project = createMemo(() => {
@@ -845,7 +741,7 @@ function MessageTimelineView(
const turnDurationMs = (userMessageID: string) => {
const message = messageByID().get(userMessageID)
if (!message || message.role !== "user") return
if (message?.type !== "user") return
const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce<number | undefined>(
(max, item) => {
const completed = item.time.completed
@@ -868,23 +764,27 @@ function MessageTimelineView(
const message = messages[i]
if (!message) continue
const parts = getMsgParts(message.id)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
const contents = Timeline.contentEntries(message)
for (let j = contents.length - 1; j >= 0; j--) {
const entry = contents[j]
if (entry?.content.type !== "text" || !entry.content.text.trim()) continue
return entry.id
}
}
}
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
if (row().group.type === "context") {
const parts = createMemo(() => {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "context") return emptyTools
return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
if (group.type !== "context") return []
return group.refs.flatMap((ref) => {
const message = messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
return message?.type === "assistant" && content?.type === "tool"
? [{ message, content, contentID: ref.partID }]
: []
})
})
const contextOpenKey = () => `context:${row().group.key}`
const open = createMemo(() => {
@@ -892,8 +792,9 @@ function MessageTimelineView(
})
return (
<ContextToolGroup
parts={parts()}
<CurrentContextToolGroup
sessionID={sessionID()!}
tools={tools()}
open={open()}
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
busy={
@@ -909,33 +810,52 @@ function MessageTimelineView(
if (group.type !== "part") return
return messageByID().get(group.ref.messageID)
})
const part = createMemo(() => {
const contentID = createMemo(() => {
const group = row().group
if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID)
return group.ref.partID
})
const content = createMemo(() => {
const current = message()
const id = contentID()
if (current?.type !== "assistant" || !id) return
return Timeline.resolveContent(current, id)
})
const defaultOpen = createMemo(() => {
const item = part()
const group = row().group
const current = message()
if (group.type !== "part" || current?.type !== "assistant") return
const item = content()
if (!item) return
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
return currentPartDefaultOpen(
sessionID()!,
current,
item,
group.ref.partID,
props.data.shellToolPartsExpanded(),
props.data.editToolPartsExpanded(),
)
})
const id = contentID()
if (!id) return
return (
<Show when={message()}>
<Show when={message()?.type === "assistant" ? (message() as SessionMessageAssistant) : undefined}>
{(message) => (
<Show when={part()}>
{(part) => (
<MessagePart
part={part()}
<Show when={content()}>
{(content) => (
<CurrentAssistantContent
sessionID={sessionID()!}
parentID={row().userMessageID}
message={message()}
content={content()}
contentID={id}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent
virtualizeDiff={false}
toolOpen={toolOpen[row().group.key] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(row().group.key, open)}
onContentRendered={onSizeChange}
/>
)}
@@ -978,20 +898,24 @@ function MessageTimelineView(
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
if (m?.type === "user") return m
})
const messageComments = createMemo(() => {
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
const current = message()
return current ? MessageComment.fromMessage(current) : []
})
const context = createMemo(() => projection.userContextByID().get(userMessageRow().userMessageID))
return (
<TimelineRowFrame row={userMessageRow()}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-content" aria-live="off">
<Message
<CurrentUserMessage
sessionID={sessionID()!}
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
agent={context()?.agent ?? ""}
model={context()?.model ?? { id: "", providerID: "" }}
actions={props.actions}
useV2Actions
comments={messageComments()}
@@ -1003,6 +927,29 @@ function MessageTimelineView(
</TimelineRowFrame>
)
}
case "Shell": {
const shellRow = row as Accessor<TimelineRowByTag<"Shell">>
const message = createMemo(() => {
const current = sessionMessageByID().get(shellRow().messageID)
return current?.type === "shell" ? current : undefined
})
return (
<TimelineRowFrame row={shellRow()}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<SessionShellMessage
message={message()}
defaultOpen={props.data.shellToolPartsExpanded()}
open={toolOpen[message().id]}
onOpenChange={(open) => setToolOpen(message().id, open)}
/>
</div>
)}
</Show>
</TimelineRowFrame>
)
}
case "Notice": {
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
const content = createMemo(() => {
@@ -1031,11 +978,7 @@ function MessageTimelineView(
<TimelineRowFrame row={turnDividerRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
<MessageDivider label={language.t("ui.message.interrupted")} />
</div>
</div>
</TimelineRowFrame>
@@ -1071,43 +1014,17 @@ function MessageTimelineView(
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
const status = createMemo(() => {
const retry = (assistantMessagesByParent().get(retryRow().userMessageID) ?? emptyAssistantMessages).at(
-1,
)?.retry
if (!retry) return sessionStatus()
return { type: "retry" as const, attempt: retry.attempt, message: retry.error.message, next: retry.at }
})
return (
<TimelineRowFrame row={retryRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
const canMove = () =>
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
!workspaceSession() &&
props.workspaceMoveEligible &&
project()?.vcs === "git" &&
sessionStatus().type === "idle"
return (
<TimelineRowFrame row={diffSummaryRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineDiffSummaryRow
diffs={diffSummaryRow().diffs}
action={
<Show when={canMove() && project()}>
{(project) => (
<WorkspaceMoveAction
variant="inline"
eligible={props.workspaceMoveEligible}
sessionID={sessionID()!}
project={project()}
directory={sessionDirectory()}
dismissed={workspaceSuggestionDismissed()}
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
/>
)}
</Show>
}
/>
<SessionRetry status={status()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
@@ -1140,10 +1057,10 @@ function MessageTimelineView(
const tool = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
if (part?.type === "tool") return part
const content = Timeline.resolveContent(messageByID().get(value.group.ref.messageID), value.group.ref.partID)
if (content?.type === "tool") return content
}
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.name ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
@@ -1,23 +1,40 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@/types"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
const assistant = (id: string): SessionMessageAssistant => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
})
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant("msg_a"), user("msg_b"), user("msg_c")]
const messages: SessionMessageInfo[] = [user("msg_a"), assistant("msg_ab"), user("msg_b"), user("msg_c")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(selectVisibleUserMessages(users)).toBe(users)
})
test("waits for an assistant-only load to hydrate its user root", () => {
expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
const currentAssistant = {
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 2 },
} satisfies SessionMessageInfo
const currentUser = { id: "msg_1", type: "user", text: "hello", time: { created: 1 } } satisfies SessionMessageInfo
expect(isTimelineReady([currentAssistant], true)).toBe(false)
expect(isTimelineReady([currentUser, currentAssistant], true)).toBe(true)
expect(isTimelineReady([], false)).toBe(true)
})
@@ -1,4 +1,4 @@
import type { Message } from "@/types"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { createMemo, createResource, type Accessor } from "solid-js"
import { useData } from "@/context/server"
import type { SessionController } from "../session-controller"
@@ -44,11 +44,13 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
userMessages: input.session.history.userMessages,
visibleUserMessages: input.session.history.visibleUserMessages,
}
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
export function isTimelineReady(messages: SessionMessageInfo[] | undefined, loading: boolean) {
return (
messages !== undefined &&
(messages.some((message) => message.type === "user" || message.type === "shell") || !loading)
)
}
export async function loadOlderTimeline(input: {
@@ -1,5 +1,4 @@
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, UserMessage } from "@/types"
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
@@ -7,39 +6,76 @@ import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
sessionMessages: Accessor<SessionMessageInfo[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
)
const userContextByID = createMemo(() => {
const result = new Map<string, { agent: string; model: ModelRef }>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "agent-switched") agent = message.agent
if (message.type === "model-switched") model = message.model
if (message.type === "user") {
userID = message.id
const metadata = message.metadata
const localAgent = typeof metadata?.agent === "string" ? metadata.agent : agent
const localModel = metadata?.model
const localModelID =
localModel && typeof localModel === "object" && !Array.isArray(localModel)
? typeof localModel.id === "string"
? localModel.id
: typeof localModel.modelID === "string"
? localModel.modelID
: undefined
: undefined
result.set(message.id, {
agent: localAgent,
model:
localModel &&
typeof localModel === "object" &&
!Array.isArray(localModel) &&
localModelID &&
typeof localModel.providerID === "string"
? {
id: localModelID,
providerID: localModel.providerID,
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
}
: model,
})
}
if (message.type === "shell") userID = undefined
if (message.type !== "assistant") return
agent = message.agent
model = message.model
if (userID) result.set(userID, { agent, model })
})
return result
})
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
input.messages().forEach((message) => {
if (message.role !== "assistant") return
const messages = result.get(message.parentID)
const result = new Map<string, Extract<SessionMessageInfo, { type: "assistant" }>[]>()
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant" || !userID) return
const messages = result.get(userID)
if (messages) {
messages.push(message)
return
}
result.set(message.parentID, [message])
result.set(userID, [message])
})
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.userMessages(),
),
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status().type),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
@@ -73,11 +109,12 @@ export function createTimelineProjection(input: {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey,
messageByID,
messageByID: sessionMessageByID,
messageRowIndex,
messageLastRowIndex,
rowByKey,
rows,
sessionMessageByID,
userContextByID,
}
}
@@ -1,16 +1,5 @@
import { describe, expect, mock, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"
mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))
const { Timeline, TimelineRow } = await import("./rows")
@@ -36,25 +25,15 @@ describe("current session timeline rows", () => {
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"assistant-part:msg_1:part:msg_2:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
"assistant-part:msg_3:part:msg_4:msg_4:reasoning:0",
])
})
@@ -71,22 +50,37 @@ describe("current session timeline rows", () => {
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual(["shell:msg_shell"])
})
test("keeps assistant content when no user root is available", () => {
const source = [
{
id: "msg_notice",
type: "synthetic",
text: "done",
description: "Background work completed",
time: { created: 1 },
},
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "result" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
"notice:msg_notice",
"assistant-part:msg_assistant:part:msg_assistant:msg_assistant:text:0",
])
})
@@ -142,97 +136,29 @@ describe("current session timeline rows", () => {
time: { created: 11 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"notice:msg_agent",
"assistant-part:msg_user:msg_assistant_1:text:0",
"assistant-part:msg_user:part:msg_assistant_1:msg_assistant_1:text:0",
"notice:msg_background",
"notice:msg_model",
"assistant-part:msg_user:msg_assistant_2:text:0",
"assistant-part:msg_user:part:msg_assistant_2:msg_assistant_2:text:0",
"notice:msg_restart",
"notice:msg_skill",
"notice:msg_compaction",
])
})
test("keeps a projected parent missing from the source page before newer turns", () => {
const source = [
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
{
id: "msg_assistant_1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "first answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } },
{
id: "msg_assistant_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "second answer" }],
time: { created: 5, completed: 6 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source.slice(1),
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user_1",
"assistant-part:msg_user_1:msg_assistant_1:text:0",
"turn-gap:msg_user_2",
"user-message:msg_user_2",
"assistant-part:msg_user_2:msg_assistant_2:text:0",
])
})
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = {
id: "msg_a",
sessionID: "ses_1",
role: "user" as const,
time: { created: 2 },
agent: "build",
model: { modelID: "model", providerID: "provider" },
}
const result = Timeline.constructSessionMessageRows(
source,
(messageID) =>
messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID),
() => [],
true,
"busy",
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
)
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe(optimistic.id)
expect(result.activeMessageID).toBe("msg_a")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_z",
"turn-gap:msg_a",
@@ -241,6 +167,25 @@ describe("current session timeline rows", () => {
])
})
test("renders retry state from the current assistant message", () => {
const source = [
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
retry: { attempt: 2, at: 10, error: { type: "ProviderError", message: "rate limited" } },
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
})
test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
@@ -262,17 +207,7 @@ describe("current session timeline rows", () => {
time: { created: 4 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
})
+149 -123
View File
@@ -1,22 +1,26 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Part, UserMessage } from "@/types"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { compareMessages } from "@/utils/session-message"
import { parseCommentNote, readPromptPresentation } from "@/utils/comment-note"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow } from "./timeline-row"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
export { TimelineRow } from "./timeline-row"
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
UserMessage: {
userMessageID: string
}
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
}
AssistantPart: {
userMessageID: string
@@ -25,22 +29,31 @@ export type TimelineRowMap = {
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
}
type Assistant = SessionMessageAssistant
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: Assistant } | { type: "notice"; message: Notice }
type Content = Assistant["content"][number]
type ContentRef = { messageID: string; partID: string }
const contextTools = new Set(["read", "glob", "grep", "list"])
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
projectedUserMessages: UserMessage[],
) {
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: AssistantMessage } | { type: "notice"; message: Notice }
const turns: { user: UserMessage; entries: Entry[] }[] = []
type Turn = {
id: string
time: { created: number }
user?: SessionMessageUser
shell?: SessionMessageShell
entries: Entry[]
}
const turns: Turn[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
const leading: Notice[] = []
let current: (typeof turns)[number] | undefined
@@ -50,80 +63,71 @@ export namespace Timeline {
if (!current) leading.push(message)
return
}
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
const turn = {
user: projected,
entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [],
}
if (message.type === "shell") {
const turn: Turn = { id: message.id, time: message.time, shell: message, entries: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
current = turn
return
}
if (projected?.role === "user") {
if (turnByUserID.has(projected.id)) return
const turn = { user: projected, entries: [] }
if (message.type === "user") {
if (turnByUserID.has(message.id)) return
const turn: Turn = { id: message.id, time: message.time, user: message, entries: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
turnByUserID.set(message.id, turn)
current = turn
return
}
if (projected?.role !== "assistant") return
const existing = current ?? turnByUserID.get(projected.parentID)
if (existing) {
existing.entries.push({ type: "assistant", message: projected })
if (message.type !== "assistant") return
const existing = current?.user ? current : undefined
if (existing?.user) {
existing.entries.push({ type: "assistant", message })
current = existing
return
}
const user = getMessage(projected.parentID)
if (user?.role !== "user") return
const turn = { user, entries: [{ type: "assistant" as const, message: projected }] }
if (current && !current.user && !current.shell) {
current.entries.push({ type: "assistant", message })
return
}
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "assistant", message }] }
turns.push(turn)
turnByUserID.set(user.id, turn)
current = turn
})
const notices = new Set(messages.filter(isNotice).map((message) => message.id))
projectedUserMessages.forEach((user) => {
if (notices.has(user.id)) return
if (turnByUserID.has(user.id)) return
const turn = { user, entries: [] }
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id
const activeMessageID = turns.at(-1)?.id
return {
activeMessageID,
rows: [
...leading.map(
(message) =>
new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }),
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
),
...turns.flatMap((turn, index) =>
constructMessageRows(
...turns.flatMap((turn, index) => {
if (turn.shell)
return [
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
...turn.entries.flatMap((entry) =>
entry.type === "notice"
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
: [],
),
]
return constructMessageRows(
turn.user,
getMessageParts,
turn.id,
turn.entries,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
),
),
turn.id === activeMessageID,
)
}),
],
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
entries: Array<
| { type: "assistant"; message: AssistantMessage }
| { type: "notice"; message: Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> }
>,
userMessage: SessionMessageUser | undefined,
turnID: string,
entries: Entry[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
@@ -133,45 +137,38 @@ export namespace Timeline {
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const compaction =
userParts.some((p) => p.type === "compaction") &&
!entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const latestError = assistantMessages.at(-1)?.error
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const error = assistantMessages.at(-1)?.error
const retry = assistantMessages.at(-1)?.retry
const interrupted = error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
contentEntries(message)
.filter((entry) => renderable(entry.content, showReasoning))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
rows.push(new TimelineRow.UserMessage({ userMessageID: userMessage.id }))
if (compaction) {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "compaction",
}),
)
}
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
let assistantGroupIndex = 0
const appendAssistants = (messages: AssistantMessage[]) => {
const appendAssistants = (messages: Assistant[]) => {
const ids = new Set(messages.map((message) => message.id))
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
const interruptedAt = messages.findIndex((message) => message.error?.name === "MessageAbortedError")
const interruptedAt = messages.findIndex(
(message) =>
message.error?.type.toLowerCase().includes("abort") ||
message.error?.type.toLowerCase().includes("interrupt"),
)
const interruptedID = messages[interruptedAt]?.id
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
const appendGroups = (items: typeof refs) =>
groupParts(items).forEach((group) => {
groupContent(items).forEach((group) => {
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
userMessageID: turnID,
group,
previousAssistantPart: assistantGroupIndex > 0,
}),
@@ -179,11 +176,10 @@ export namespace Timeline {
assistantGroupIndex += 1
})
appendGroups(before)
if (interruptedAt >= 0 && !compaction)
rows.push(new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted" }))
if (interruptedAt >= 0 && !compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
appendGroups(after)
}
let assistantSegment: AssistantMessage[] = []
let assistantSegment: Assistant[] = []
entries.forEach((entry) => {
if (entry.type === "assistant") {
assistantSegment.push(entry.message)
@@ -191,44 +187,31 @@ export namespace Timeline {
}
appendAssistants(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id }))
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
})
appendAssistants(assistantSegment)
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
if (isActive && status === "busy" && !error && !retry && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
.flatMap((message) => message.content)
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: userMessage.id,
userMessageID: turnID,
reasoningHeading: heading,
}),
)
}
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID }))
const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs)
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
userMessageID: userMessage.id,
diffs,
}),
)
}
if (error) {
const data = error.data && "message" in error.data ? error.data.message : undefined
if (error && !interrupted) {
rows.push(
new TimelineRow.Error({
userMessageID: userMessage.id,
text: unwrapErrorMessage(
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
),
userMessageID: turnID,
text: unwrapErrorMessage(error.message),
}),
)
}
@@ -236,6 +219,52 @@ export namespace Timeline {
return rows
}
export function resolveContent(message: SessionMessageInfo | undefined, partID: string) {
if (message?.type !== "assistant") return
return contentEntries(message).find((entry) => entry.id === partID)?.content
}
export function contentEntries(message: Assistant) {
const ordinals = { text: 0, reasoning: 0 }
return message.content.map((content) => ({
id: content.type === "tool" ? content.id : `${message.id}:${content.type}:${ordinals[content.type]++}`,
content,
}))
}
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
if (content.name === "todowrite") return false
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
return true
}
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let context: ContentRef[] = []
const flush = () => {
const first = context[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
groups.push({
type: "part",
key: `part:${item.messageID}:${item.partID}`,
ref: { messageID: item.messageID, partID: item.partID },
})
})
flush()
return groups
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
@@ -341,19 +370,16 @@ export namespace MessageComment {
}
}
export const fromPart = (part: Part): MessageComment | undefined => {
if (part.type !== "text" || !part.synthetic) return
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return
return {
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
export const fromMessage = (message: SessionMessageUser): MessageComment[] => {
const presentation = readPromptPresentation(message.metadata)
const parsed = presentation ? undefined : parseCommentNote(message.text)
const comments = presentation?.comments ?? (parsed ? [parsed] : [])
return comments.map((comment) => ({
path: comment.path,
comment: comment.comment,
selection: comment.selection
? { startLine: comment.selection.startLine, endLine: comment.selection.endLine }
: undefined,
}
}))
}
}
@@ -1,41 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { uniqueSummaryDiffs } from "./summary-diffs"
const diff = (file: string, additions: number) =>
({
file,
patch: "",
additions,
deletions: 0,
status: "modified",
}) satisfies FileDiffInfo
describe("uniqueSummaryDiffs", () => {
test("drops entries without files and preserves unique input", () => {
const alpha = diff("alpha.ts", 1)
const beta = diff("beta.ts", 1)
expect(uniqueSummaryDiffs(undefined)).toEqual([])
expect(uniqueSummaryDiffs([])).toEqual([])
const result = uniqueSummaryDiffs([alpha, beta])
expect(result).toEqual([alpha, beta])
expect(result[0]).toBe(alpha)
expect(result[1]).toBe(beta)
})
test("keeps the last diff per file in display order", () => {
const oldAlpha = diff("alpha.ts", 1)
const oldBeta = diff("beta.ts", 1)
const newAlpha = diff("alpha.ts", 2)
const charlie = diff("charlie.ts", 1)
const newBeta = diff("beta.ts", 2)
const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta])
expect(result).toEqual([newAlpha, charlie, newBeta])
expect(result[0]).toBe(newAlpha)
expect(result[1]).toBe(charlie)
expect(result[2]).toBe(newBeta)
})
})
@@ -1,20 +0,0 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SummaryDiff } from "./timeline-row"
export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) {
const files = new Set<string>()
return (diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
if (!isSummaryDiff(diff)) return result
const file = diff.file
if (files.has(file)) return result
files.add(file)
result.push(diff)
return result
}, [])
.reverse()
}
function isSummaryDiff(diff: FileDiffInfo): diff is SummaryDiff {
return typeof diff.file === "string"
}
@@ -1,9 +1,6 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = FileDiffInfo
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
@@ -11,13 +8,16 @@ export namespace TimelineRow {
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
}> {}
export class Shell extends Data.TaggedClass("Shell")<{
userMessageID: string
messageID: string
}> {}
export class Notice extends Data.TaggedClass("Notice")<{
userMessageID: string
messageID: string
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
@@ -28,10 +28,6 @@ export namespace TimelineRow {
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
@@ -43,11 +39,11 @@ export namespace TimelineRow {
export type TimelineRow =
| TurnGap
| UserMessage
| Shell
| Notice
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
@@ -57,16 +53,16 @@ export namespace TimelineRow {
return `turn-gap:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "Shell":
return `shell:${row.messageID}`
case "Notice":
return `notice:${row.messageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
return `turn-divider:${row.userMessageID}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
@@ -52,13 +52,13 @@ export function useUsageExceededDialogs() {
onCleanup(
sdk().event.on("session.status", (evt) => {
if (evt.properties.sessionID !== params.id) return
if (evt.properties.status.type !== "retry") return
const { action } = evt.properties.status
if (evt.data.sessionID !== params.id) return
if (evt.data.status.type !== "retry") return
const { action } = evt.data.status
if (!action) return
if (dialog.active) return
const keys = goUpsellKeys(evt.properties.status)
const keys = goUpsellKeys(evt.data.status)
if (!keys) return
const seen = goUpsellState[keys.lastSeenAt]
@@ -7,12 +7,14 @@ import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import type { UserMessage } from "@/types"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
@@ -31,7 +33,7 @@ export type SessionCommandContext = {
move: () => Promise<void>
}
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
setActiveMessage: (message: SessionMessageUser | undefined) => void
focusInput: () => void
}
@@ -51,6 +53,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const prompt = usePrompt()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const terminal = useTerminal()
const layout = useLayout()
@@ -59,6 +62,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const value = await load()
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<SessionController["ownership"]["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
updateViewport: () => void
}) => {
await input.request()
input.updatePrompt(input.prompt)
input.owner.run(input.updateViewport)
}
const shown = settings.visibility.fileTree
const showAllFiles = () => {
@@ -86,6 +100,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
@@ -273,6 +288,75 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
}
const undo = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const owner = actions.session.ownership.capture()
const session = serverSDK.api.session
const promptSession = prompt.capture()
const revert = actions.session.data.revertMessageID()
const messages = actions.session.history.userMessages()
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
if (!message) return
if (data.session.status(sessionID) === "running") await session.interrupt({ sessionID }).catch(() => {})
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: message.id }),
updatePrompt: (target) => {
target.set(extractPromptFromMessage(message, { directory: sdk().directory }))
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
},
updateViewport: () => setActiveMessage(messages[boundary - 2]),
})
}
const redo = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const owner = actions.session.ownership.capture()
const session = serverSDK.api.session
const messages = actions.session.history.userMessages()
const promptSession = prompt.capture()
const revertMessageID = actions.session.data.revertMessageID()
if (!revertMessageID) return
const boundary = messages.findIndex((message) => message.id === revertMessageID)
if (boundary < 0) return
const next = messages[boundary + 1]
if (!next) {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
updatePrompt: (target) => target.reset(),
updateViewport: () => setActiveMessage(messages.at(-1)),
})
return
}
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(messages[boundary]),
})
}
const compact = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@@ -332,18 +416,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
// TODO: Restore undo when current transcript parts can reconstruct the prompt draft.
disabled: true,
onSelect: () => undefined,
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
// TODO: Restore redo with the current transcript projection.
disabled: true,
onSelect: () => undefined,
disabled: !actions.session.identity.params.id || !actions.session.data.revertMessageID(),
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
@@ -1,4 +1,4 @@
import type { UserMessage } from "@/types"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
@@ -7,14 +7,14 @@ export const useSessionHashScroll = (input: {
sessionKey: () => string
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
visibleUserMessages: () => SessionMessageUser[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setActiveMessage: (message: SessionMessageUser | undefined) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
@@ -85,7 +85,7 @@ export const useSessionHashScroll = (input: {
return false
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
const scrollToMessage = (message: SessionMessageUser, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
input.revealMessage?.(message.id)
+1 -16
View File
@@ -1,9 +1,4 @@
import type {
EventSubscribeOutput,
FileDiffInfo,
ProjectListOutput,
WorktreeDirectory,
} from "@opencode-ai/client/promise"
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
@@ -11,16 +6,6 @@ export type Project = Omit<ProjectListOutput[number], "canonical"> & {
worktrees: WorktreeDirectory[]
}
type CurrentEvent = EventSubscribeOutput extends infer Item
? Item extends { type: infer Type extends string; data: infer Data }
? { type: Type; properties: Data }
: never
: never
export type Event = CurrentEvent
export type EventSessionError = Extract<Event, { type: "session.execution.failed" }>
type MessageError =
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
| { name: "UnknownError"; data: { message: string; ref?: string } }
+27
View File
@@ -53,6 +53,33 @@ export function readCommentMetadata(value: unknown) {
} satisfies PromptComment
}
export function readPromptPresentation(value: unknown) {
if (!value || typeof value !== "object") return
const displayText = (value as { displayText?: unknown }).displayText
const comments = (value as { comments?: unknown }).comments
if (typeof displayText !== "string" || !Array.isArray(comments)) return
return {
displayText,
comments: comments.flatMap((item): PromptComment[] => {
if (!item || typeof item !== "object") return []
const path = (item as { path?: unknown }).path
const comment = (item as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return []
const preview = (item as { preview?: unknown }).preview
const origin = (item as { origin?: unknown }).origin
return [
{
path,
comment,
selection: selection((item as { selection?: unknown }).selection),
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
},
]
}),
}
}
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
+1 -40
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types"
import { diffs, message } from "./diffs"
import { diffs } from "./diffs"
const item = {
file: "src/app.ts",
@@ -34,41 +33,3 @@ describe("diffs", () => {
).toEqual([item])
})
})
describe("message", () => {
test("normalizes user summaries with object diffs", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: {
title: "Edit",
diffs: { a: item },
},
} as unknown as Message
expect(message(input)).toMatchObject({
summary: {
title: "Edit",
diffs: [item],
},
})
})
test("drops invalid user summaries", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: true,
} as unknown as Message
expect(message(input)).toMatchObject({ summary: undefined })
})
})
-24
View File
@@ -1,5 +1,4 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types"
type Diff = FileDiffInfo
@@ -24,26 +23,3 @@ export function diffs(value: unknown): Diff[] {
if (!object(value)) return []
return Object.values(value).filter(diff)
}
export function message(value: Message): Message {
if (value.role !== "user") return value
const raw = value.summary as unknown
if (raw === undefined) return value
if (!object(raw)) return { ...value, summary: undefined }
const title = typeof raw.title === "string" ? raw.title : undefined
const body = typeof raw.body === "string" ? raw.body : undefined
const next = diffs(raw.diffs)
if (title === raw.title && body === raw.body && next === raw.diffs) return value
return {
...value,
summary: {
...(title === undefined ? {} : { title }),
...(body === undefined ? {} : { body }),
diffs: next,
},
}
}
+85 -31
View File
@@ -1,38 +1,21 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@/types"
import { extractPromptFromParts } from "./prompt"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
describe("extractPromptFromParts", () => {
describe("extractPromptFromMessage", () => {
test("restores multiple uploaded attachments", () => {
const parts = [
{
id: "text_1",
type: "text",
text: "check these",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_1",
type: "file",
mime: "image/png",
url: "data:image/png;base64,AAA",
filename: "a.png",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_2",
type: "file",
mime: "application/pdf",
url: "data:application/pdf;base64,BBB",
filename: "b.pdf",
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]
const message = {
id: "msg_1",
type: "user",
text: "check these",
files: [
{ data: "AAA", mime: "image/png", source: { type: "inline" }, name: "a.png" },
{ data: "BBB", mime: "application/pdf", source: { type: "inline" }, name: "b.pdf" },
],
time: { created: 1 },
} satisfies SessionMessageUser
const result = extractPromptFromParts(parts)
const result = extractPromptFromMessage(message)
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
@@ -51,4 +34,75 @@ describe("extractPromptFromParts", () => {
},
])
})
test("restores optimistic data URLs and review comments", () => {
const message = {
id: "msg_1",
type: "user",
text: "model text",
metadata: {
displayText: "visible text",
comments: [
{
path: "src/app.ts",
comment: "check this",
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 4 },
origin: "review",
},
],
},
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "data:image/png;base64,AAA" },
name: "a.png",
},
],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "visible text" },
{ type: "image", filename: "a.png", mime: "image/png" },
])
expect(extractPromptComments(message)).toMatchObject([
{ path: "src/app.ts", comment: "check this", origin: "review" },
])
})
test("keeps the directory of a file mention without an at-sign", () => {
const message = {
id: "msg_1",
type: "user",
text: "inspect src/client.ts",
files: [
{
data: "",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/src/client.ts" },
name: "client.ts",
mention: { text: "src/client.ts", start: 8, end: 21 },
},
],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "src/client.ts", path: "src/client.ts" },
])
})
test("uses model text when presentation metadata is incomplete", () => {
const message = {
id: "msg_1",
type: "user",
text: "model text",
metadata: { displayText: "partial display text" },
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
})
+50 -74
View File
@@ -1,6 +1,7 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { createLegacyBlobReference } from "@/utils/draft-store"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { readPromptPresentation } from "./comment-note"
type Inline =
| {
@@ -39,94 +40,69 @@ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["s
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
export function extractPromptFromMessage(
message: SessionMessageUser,
opts?: { directory?: string; attachmentName?: string },
): Prompt {
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
if (path.startsWith(directory)) {
const next = path.slice(directory.length)
if (next.startsWith("/")) return next.slice(1)
return next
}
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "file") {
const filePart = part as FilePart
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
inline.push({
type: "file",
start,
end,
value,
path: toRelative(path),
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? attachmentName,
mime: filePart.mime,
blob: createLegacyBlobReference(filePart.url),
})
}
}
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
for (const file of message.files ?? []) {
const mention = file.mention
const uri = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
if (mention) {
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
type: "file",
start: mention.start,
end: mention.end,
value: mention.text,
path: toRelative(mention.text.startsWith("@") ? mention.text.slice(1) : mention.text),
selection: selectionFromFileUrl(uri),
})
continue
}
const dataUrl =
file.source.type === "uri" && file.source.uri.startsWith("data:")
? file.source.uri
: file.data
? `data:${file.mime};base64,${file.data}`
: undefined
if (!dataUrl) continue
images.push({
type: "image",
id: `${message.id}:file:${images.length}`,
filename: file.name ?? attachmentName,
mime: file.mime,
blob: createLegacyBlobReference(dataUrl),
})
}
for (const agent of message.agents ?? []) {
const mention = agent.mention
if (!mention) continue
inline.push({
type: "agent",
start: mention.start,
end: mention.end,
value: mention.text,
name: agent.name,
})
}
return buildPrompt(text, inline, images)
}
export function extractPromptComments(message: SessionMessageUser) {
return readPromptPresentation(message.metadata)?.comments ?? []
}
function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart[]): Prompt {
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
+94 -180
View File
@@ -1,93 +1,30 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "./session-message"
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
import { presentAssistantParts, presentUserParts } from "./session-message"
describe("normalizeSessionMessages", () => {
test("projects current turns into stable timeline rendering records", () => {
const source = [
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_2",
type: "model-switched",
model: { id: "claude", providerID: "anthropic", variant: "high" },
time: { created: 2 },
},
{
id: "msg_3",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "aGVsbG8=",
mime: "text/plain",
name: "note.txt",
source: { type: "inline" },
},
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 3 },
},
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 5, ran: 6, completed: 7 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 4, completed: 7 },
},
{
id: "msg_5",
type: "compaction",
status: "completed",
reason: "auto",
summary: "summary",
recent: "recent",
time: { created: 8 },
},
] satisfies SessionMessageInfo[]
describe("session message presentation", () => {
test("projects current user content for the DOM renderer", () => {
const message = {
id: "msg_user",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 1 },
} satisfies SessionMessageUser
const result = normalizeSessionMessages("ses_1", source)
const parts = presentUserParts("ses_1", message)
expect(result.messages).toHaveLength(2)
expect(result.messages[0]).toMatchObject({
id: "msg_3",
role: "user",
agent: "build",
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
})
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
"msg_3:text:0",
"msg_3:file:0",
"msg_3:file:1",
"msg_3:agent:0",
"msg_5:compaction",
])
expect(result.parts.get("msg_3")?.[2]).toMatchObject({
expect(parts.map((part) => part.id)).toEqual(["msg_user:text:0", "msg_user:file:0", "msg_user:agent:0"])
expect(parts[1]).toMatchObject({
type: "file",
source: {
type: "file",
@@ -95,109 +32,86 @@ describe("normalizeSessionMessages", () => {
text: { value: "@src/client.ts", start: 8, end: 22 },
},
})
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
type: "tool",
tool: "read",
state: { status: "completed", output: "hello" },
const plainMention = {
...message,
text: "inspect src/client.ts",
files: [
{
...message.files[0],
name: "client.ts",
mention: { text: "src/client.ts", start: 8, end: 21 },
},
],
} satisfies SessionMessageUser
expect(presentUserParts("ses_1", plainMention)[1]).toMatchObject({
type: "file",
source: { type: "file", path: "src/client.ts" },
})
})
test("does not invent a parent for an assistant-only page", () => {
const source = [
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "orphan" }],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
})
test("projects a current shell message into a renderable standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "printf hello",
status: "exited",
exit: 0,
output: { output: "hello", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.messages).toEqual([
expect.objectContaining({ id: "msg_shell", role: "user" }),
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
])
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
expect(result.parts.get("msg_shell:assistant")).toEqual([
expect.objectContaining({
type: "tool",
tool: "bash",
state: expect.objectContaining({
status: "completed",
input: { command: "printf hello" },
output: "hello",
title: "Shell",
}),
}),
])
})
test("adapts current edit fields for the edit renderer", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [
{
file: "README.md",
patch: "@@ -1 +1 @@\n-old\n+new",
additions: 1,
deletions: 1,
status: "modified",
},
],
replacements: 1,
},
},
time: { created: 2, ran: 3, completed: 4 },
test("projects current assistant content for existing DOM tools", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
],
time: { created: 2, completed: 4 },
},
] satisfies SessionMessageInfo[]
time: { created: 3, ran: 4, completed: 5 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 2, completed: 5 },
} satisfies SessionMessageAssistant
const result = normalizeSessionMessages("ses_1", source)
const parts = presentAssistantParts("ses_1", message)
expect(result.parts.get("msg_assistant")).toEqual([
expect(parts.map((part) => part.id)).toEqual(["msg_assistant:reasoning:0", "msg_assistant:text:0", "call_1"])
expect(parts[2]).toMatchObject({ type: "tool", tool: "read", state: { status: "completed", output: "hello" } })
})
test("adapts current edit fields only at the renderer boundary", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [{ file: "README.md", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1 }],
},
},
time: { created: 2, ran: 3, completed: 4 },
},
],
time: { created: 2, completed: 4 },
} satisfies SessionMessageAssistant
expect(presentAssistantParts("ses_1", message)).toEqual([
expect.objectContaining({
type: "tool",
tool: "edit",
state: expect.objectContaining({
status: "completed",
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
metadata: expect.objectContaining({
filediff: {
+53 -178
View File
@@ -1,25 +1,15 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@/types"
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
import { Option, Schema } from "effect"
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
const decodeToolInput = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export function compareMessages(a: Pick<Message, "id" | "time">, b: Pick<Message, "id" | "time">) {
const left = messageKey(a)
const right = messageKey(b)
return left < right ? -1 : left > right ? 1 : 0
}
export const messageKey = (message: Pick<Message, "id" | "time">) => message.time.created + message.id
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@@ -45,148 +35,11 @@ function normalizeToolMetadata(name: string, metadata: Record<string, unknown>)
}
}
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
const messages: Message[] = []
const parts = new Map<string, Part[]>()
let agent = ""
let model = emptyModel
let parentID: string | undefined
source.forEach((message) => {
if (message.type === "agent-switched") {
agent = message.agent
return
}
if (message.type === "model-switched") {
model = message.model
return
}
if (message.type === "user") {
parentID = message.id
messages.push(userMessage(sessionID, message, agent, model))
parts.set(message.id, userParts(sessionID, message))
return
}
if (message.type === "synthetic" && message.description?.trim()) {
parentID = message.id
messages.push({
id: message.id,
sessionID,
role: "user",
time: message.time,
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
})
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
return
}
if (message.type === "shell") {
messages.push(...shellMessages(sessionID, message, agent, model))
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
parentID = undefined
return
}
if (message.type === "assistant") {
agent = message.agent
model = message.model
if (!parentID) return
const parent = messages.findLast((item) => item.id === parentID)
if (parent?.role === "user") {
parent.agent = message.agent
parent.model = {
providerID: message.model.providerID,
modelID: message.model.id,
variant: message.model.variant,
}
}
messages.push(assistantMessage(sessionID, parentID, message))
parts.set(message.id, assistantParts(sessionID, message))
return
}
if (message.type !== "compaction" || !parentID) return
parts.set(parentID, [
...(parts.get(parentID) ?? []),
{
id: `${message.id}:compaction`,
sessionID,
messageID: parentID,
type: "compaction",
auto: message.reason === "auto",
},
])
})
return { messages, parts }
}
function shellMessages(
sessionID: string,
message: SessionMessageShell,
agent: string,
model: { id: string; providerID: string; variant?: string },
): [UserMessage, AssistantMessage] {
return [
{
id: message.id,
sessionID,
role: "user",
time: { created: message.time.created },
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
},
{
id: `${message.id}:assistant`,
sessionID,
role: "assistant",
time: message.time,
parentID: message.id,
modelID: model.id,
providerID: model.providerID,
variant: model.variant,
mode: agent,
agent,
path: { cwd: "", root: "" },
cost: 0,
tokens: emptyTokens,
},
]
}
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
const input = { command: message.command }
const start = message.time.created
const state: ToolPart["state"] =
message.status === "running"
? { status: "running", input, time: { start } }
: {
status: "completed",
input,
output: message.output?.output ?? "",
title: "Shell",
metadata: {
status: message.status,
exit: message.exit,
truncated: message.output?.truncated,
},
time: { start, end: message.time.completed ?? start },
}
return {
id: `${message.id}:tool`,
sessionID,
messageID: `${message.id}:assistant`,
type: "tool",
callID: message.shellID,
tool: "bash",
state,
}
}
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
return `${messageID}:${type}:${ordinal}`
}
function userMessage(
export function presentUserMessage(
sessionID: string,
message: SessionMessageUser,
agent: string,
@@ -202,9 +55,11 @@ function userMessage(
}
}
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
return [
...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,
@@ -218,7 +73,7 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
? {
type: "file",
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text),
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : file.mention.text,
}
: undefined,
}),
@@ -235,10 +90,25 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
: undefined,
}),
),
...(presentation?.comments ?? []).map(
(comment, index): Part => ({
id: `${message.id}:comment:${index}`,
sessionID,
messageID: message.id,
type: "text",
text: formatCommentNote(comment),
synthetic: true,
metadata: createCommentMetadata(comment),
}),
),
]
}
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
export function presentAssistantMessage(
sessionID: string,
parentID: string,
message: SessionMessageAssistant,
): AssistantMessage {
const error = message.error
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
@@ -263,32 +133,40 @@ function assistantMessage(sessionID: string, parentID: string, message: SessionM
}
}
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
export function presentAssistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
const ordinals = { text: 0, reasoning: 0 }
return message.content.flatMap((content): Part[] => {
if (content.type === "text") {
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
return content.text.trim() ? [part] : []
}
if (content.type === "reasoning") {
const part: Part = {
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return content.text.trim() ? [part] : []
}
return [toolPart(sessionID, message.id, content)]
const id =
content.type === "tool" ? content.id : sessionMessagePartID(message.id, content.type, ordinals[content.type]++)
const part = presentAssistantContent(sessionID, message, id, content)
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return []
return [part]
})
}
export function presentAssistantContent(
sessionID: string,
message: SessionMessageAssistant,
id: string,
content: SessionMessageAssistant["content"][number],
): Part {
if (content.type === "text") return { id, sessionID, messageID: message.id, type: "text", text: content.text }
if (content.type === "reasoning")
return {
id,
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return toolPart(sessionID, message.id, content)
}
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
return {
id: sessionMessagePartID(messageID, "text", ordinal),
@@ -312,7 +190,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start },
}
@@ -322,7 +199,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
}
@@ -347,7 +223,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
+9 -6
View File
@@ -3,6 +3,7 @@ import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url"
import { existsSync } from "fs"
import { UpdateArtifact } from "../../../script/update-artifact"
const dir = fileURLToPath(new URL("..", import.meta.url))
@@ -79,12 +80,14 @@ await publishDistribution({
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
})
await publishDistribution({
root: "./dist/node",
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
})
if (existsSync("./dist/node")) {
await publishDistribution({
root: "./dist/node",
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
})
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
-1
View File
@@ -89,7 +89,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
wal: process.env.OPENCODE_DB_WAL === undefined ? undefined : truthy(process.env.OPENCODE_DB_WAL),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
+33 -13
View File
@@ -9,7 +9,7 @@ import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
type Method = "npm" | "pnpm" | "bun" | "yarn"
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
@@ -68,6 +68,14 @@ export const layer = Layer.effect(
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
@@ -104,21 +112,33 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun">, string[]> = {
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* method === "bun"
? Effect.scoped(
Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes")
}),
)
: run(commands[method], "5 minutes")
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
}),
)
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
+1
View File
@@ -5,6 +5,7 @@ export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
+57
View File
@@ -0,0 +1,57 @@
export * as ConfigMCPPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { MCP } from "../../mcp/index.js"
export const Plugin = define({
id: "opencode.config.mcp",
effect: Effect.fn(function* (ctx) {
yield* register(ctx.event.subscribe())
}),
})
export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
events: Stream.Stream<{ readonly type: string }, unknown>,
) {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const loaded = { entries: [] as Entry[] }
yield* events.pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(mcp.reload()),
Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause })),
),
),
Effect.ignore,
Effect.forkScoped({ startImmediately: true }),
)
// Subscribe before the initial load so updates racing it trigger a rebuild.
loaded.entries = yield* config.entries()
yield* mcp.transform((draft) => {
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
// Global timeout defaults merge in config order; each server can override them.
const timeout = Object.assign(
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, Mcp.ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, server)
}
})
})
+3 -3
View File
@@ -18,7 +18,6 @@ export interface Interface {
export const Options = Schema.Struct({
path: Schema.optional(Schema.String),
wal: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -30,9 +29,11 @@ const databaseLayer = Layer.effect(
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
@@ -45,8 +46,7 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) =>
layerFromClient.pipe(Layer.provide(sqliteLayer({ filename, wal: options.wal })))
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -1,17 +0,0 @@
import { statfsSync } from "node:fs"
const NETWORK_FILESYSTEM_TYPES = new Set([
0x0000517b, // SMB
0x01021997, // 9P
0x65735546, // FUSE (including VirtioFS and SSHFS)
0x00006969, // NFS
0xff534d42, // CIFS
])
export function isNetworkFilesystemType(type: number) {
return NETWORK_FILESYSTEM_TYPES.has(type >>> 0)
}
export function isNetworkFilesystem(filename: string) {
return isNetworkFilesystemType(statfsSync(filename).type)
}
+2 -8
View File
@@ -3,7 +3,6 @@ import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { isNetworkFilesystem } from "./network-filesystem.js"
import { Sqlite } from "./sqlite.js"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
@@ -18,7 +17,7 @@ interface Config extends Sqlite.ClientConfig {
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly wal?: boolean
readonly disableWAL?: boolean
}
const make = (options: Config) =>
@@ -91,12 +90,7 @@ const nativeLayer = (config: Config) =>
create: config.create ?? true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
const wal = config.filename !== ":memory:" && (config.wal ?? !isNetworkFilesystem(config.filename))
if (wal) {
native.run("PRAGMA journal_mode = WAL;")
native.run("PRAGMA wal_checkpoint(PASSIVE);")
}
if (!wal && config.filename !== ":memory:") native.run("PRAGMA journal_mode = DELETE;")
if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;")
return native
}),
)
+2 -9
View File
@@ -3,7 +3,6 @@ import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { isNetworkFilesystem } from "./network-filesystem.js"
import { Sqlite } from "./sqlite.js"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
@@ -18,7 +17,7 @@ interface Config extends Sqlite.ClientConfig {
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly wal?: boolean
readonly disableWAL?: boolean
readonly timeout?: number
readonly allowExtension?: boolean
}
@@ -88,13 +87,7 @@ const nativeLayer = (config: Config) =>
open: true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
const wal = config.filename !== ":memory:" && (config.wal ?? !isNetworkFilesystem(config.filename))
if (wal && config.readonly !== true) {
native.exec("PRAGMA journal_mode = WAL;")
native.exec("PRAGMA wal_checkpoint(PASSIVE);")
}
if (!wal && config.filename !== ":memory:" && config.readonly !== true)
native.exec("PRAGMA journal_mode = DELETE;")
if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;")
return native
}),
)
+83 -5
View File
@@ -114,6 +114,23 @@ type NextProject = {
readonly commands: string | null
}
type NextColumns<A> = Record<keyof A, "required" | "nullable" | { readonly fallback: keyof A & string }>
const NEXT_PROJECT_COLUMNS = {
id: "required",
worktree: "required",
vcs: "nullable",
name: "nullable",
icon_url: "nullable",
icon_url_override: { fallback: "icon_url" },
icon_color: "nullable",
time_created: "required",
time_updated: "required",
time_initialized: "nullable",
sandboxes: "required",
commands: "nullable",
} satisfies NextColumns<NextProject>
type NextSession = {
readonly id: string
readonly project_id: string
@@ -149,6 +166,41 @@ type NextSession = {
readonly time_suspended: number | null
}
const NEXT_SESSION_COLUMNS = {
id: "required",
project_id: "required",
workspace_id: "nullable",
parent_id: "nullable",
fork_session_id: "nullable",
fork_boundary: "nullable",
slug: "required",
directory: "required",
path: "nullable",
title: "nullable",
version: "required",
share_url: "nullable",
summary_additions: "nullable",
summary_deletions: "nullable",
summary_files: "nullable",
summary_diffs: "nullable",
metadata: "nullable",
cost: "required",
tokens_input: "required",
tokens_output: "required",
tokens_reasoning: "required",
tokens_cache_read: "required",
tokens_cache_write: "required",
revert: "nullable",
permission: "nullable",
agent: "nullable",
model: "nullable",
time_created: "required",
time_updated: "required",
time_compacting: "nullable",
time_archived: "nullable",
time_suspended: "nullable",
} satisfies NextColumns<NextSession>
type NextMessage = {
readonly id: string
readonly session_id: string
@@ -686,12 +738,9 @@ function importNextDatabase(
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
selectNextRows<NextProject>(source, "project", NEXT_PROJECT_COLUMNS).map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
const sessions = selectNextRows<NextSession>(source, "session", NEXT_SESSION_COLUMNS)
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
@@ -789,6 +838,35 @@ function isNextDatabase(source: SQLiteDatabase) {
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function selectNextRows<A>(source: SQLiteDatabase, table: "project" | "session", definition: NextColumns<A>) {
const columns = new Set(
source
.query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?)")
.all(table)
.map((column) => column.name),
)
const missing = Object.entries(definition)
.filter(([column, strategy]) => strategy === "required" && !columns.has(column))
.map(([column]) => column)
if (missing.length)
throw new Error(`Incompatible opencode-next.db: ${table} is missing required columns: ${missing.join(", ")}`)
const projection = Object.entries(definition).map(([column, strategy]) => {
if (columns.has(column)) return `"${column}"`
if (
typeof strategy === "object" &&
strategy !== null &&
"fallback" in strategy &&
typeof strategy.fallback === "string" &&
columns.has(strategy.fallback)
)
return `"${strategy.fallback}" AS "${column}"`
return `NULL AS "${column}"`
})
return source
.query<A, []>(`SELECT ${projection.join(", ")} FROM "${table}"${table === "session" ? ' ORDER BY "id" DESC' : ""}`)
.all()
}
function row(
source: SourceMessage,
message: {
+114 -98
View File
@@ -3,13 +3,10 @@ export * as MCP from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { Document, Event, type Entry } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Semaphore, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -112,7 +109,7 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
}) {}
type ServerEntry = {
readonly config: typeof ConfigMCP.Server.Type
readonly config: Mcp.ServerConfig
status: Status
readonly startup: Deferred.Deferred<void>
scope?: Scope.Closeable
@@ -129,9 +126,25 @@ type ServerEntry = {
const GLOBAL_ELICITATION_SESSION_ID = "global"
const URL_ELICITATION_FIELD_KEY = "elicitation"
export interface Interface {
type Data = {
servers: Map<ServerName, Types.DeepMutable<Mcp.ServerConfig>>
removed: Set<ServerName>
}
export type Draft = {
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
update: (server: ServerName | string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void) => void
remove: (server: ServerName | string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) =>
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect<void>
readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect<void>
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
readonly disconnect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
readonly remove: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
@@ -171,7 +184,6 @@ export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const location = yield* Location.Service
const environment = yield* Environment.Service
const bus = yield* Bus.Service
@@ -181,37 +193,13 @@ export const layer = (options?: Options) =>
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loadConfig = (entries: readonly Entry[]) => {
const documents = entries.filter((entry): entry is Document => entry.type === "document")
// Global MCP timeout defaults, later config files overriding earlier ones.
const timeout = Object.assign(
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<ServerName, typeof ConfigMCP.Server.Type>()
for (const entry of documents) {
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
servers.set(ServerName.make(name), { ...server, timeout: { ...timeout, ...server.timeout } })
}
}
return { timeout, servers }
}
const initial = loadConfig(yield* config.entries())
const configState = { servers: initial.servers, timeout: initial.timeout }
// Later config files win for duplicate server names; per-server timeout overrides globals.
const runtime = new Map<ServerName, ServerEntry>()
// Materialized definitions and live connections are kept separate so operational additions
// survive unrelated definition reloads.
const entries = new Map<ServerName, ServerEntry>()
// Serializes lifecycle operations per server. Anything taking this lock from a connection
// callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
const locks = KeyedMutex.makeUnsafe<ServerName>()
const reloadLock = Semaphore.makeUnsafe(1)
const urlElicitations = new Map<string, Form.ID>()
for (const [name, server] of initial.servers) {
runtime.set(name, {
config: server,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}
// Register every remote server as an OAuth integration so credentials live in the global store
// rather than in committed config. Servers that connect anonymously simply never use the method.
@@ -253,11 +241,9 @@ export const layer = (options?: Options) =>
})
.pipe(Scope.provide(scope))
})
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
const name = ServerName.make(server)
const entry = runtime.get(name)
const entry = entries.get(name)
if (!entry) return yield* new NotFoundError({ server: name })
return { name, entry }
})
@@ -438,13 +424,11 @@ export const layer = (options?: Options) =>
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.prompts().pipe(
Effect.catch(() => Effect.succeed([])),
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(bus.publish(Command.Event.Updated, {})),
Effect.catch(() =>
Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))),
),
)
// Runs a connection callback under the server lock, dropping it if the connection is no longer
@@ -572,15 +556,15 @@ export const layer = (options?: Options) =>
if (entry.registration) yield* entry.registration.dispose
})
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: typeof ConfigMCP.Server.Type) {
const previous = runtime.get(name)
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: Mcp.ServerConfig) {
const previous = entries.get(name)
if (previous) yield* disposeServer(name, previous)
const entry: ServerEntry = {
config: serverConfig,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
}
runtime.set(name, entry)
entries.set(name, entry)
yield* Effect.gen(function* () {
yield* register(name, entry)
if (serverConfig.disabled) {
@@ -596,55 +580,66 @@ export const layer = (options?: Options) =>
})
const removeServer = Effect.fnUntraced(function* (name: ServerName) {
const entry = runtime.get(name)
const entry = entries.get(name)
if (!entry) return
yield* disposeServer(name, entry)
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
runtime.delete(name)
entries.delete(name)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
})
const reloadConfig = Effect.fnUntraced(function* () {
yield* reloadLock.withPermit(
Effect.gen(function* () {
const next = loadConfig(yield* config.entries())
const names = new Set([...configState.servers.keys(), ...next.servers.keys()])
for (const name of names) {
const previous = configState.servers.get(name)
const updated = next.servers.get(name)
if (isDeepStrictEqual(previous, updated)) continue
if (!updated) {
yield* removeServer(name).pipe(locks.withLock(name))
continue
}
yield* replaceServer(name, updated).pipe(locks.withLock(name))
}
configState.servers = next.servers
configState.timeout = next.timeout
}),
)
})
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
config: server,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
applied = servers
// Disabled servers settle their startup immediately so queries never block on them.
for (const [name, entry] of runtime) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
Deferred.doneUnsafe(entry.startup, Exit.void)
continue
// Initial connections stay asynchronous so one slow server does not block Location startup.
for (const [name, entry] of entries) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
Deferred.doneUnsafe(entry.startup, Exit.void)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
continue
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
}
return
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
}
const names = new Set([...(applied?.keys() ?? []), ...servers.keys()])
for (const name of names) {
const previous = applied?.get(name)
const updated = servers.get(name)
if (isDeepStrictEqual(previous, updated)) continue
if (!updated) {
yield* removeServer(name).pipe(locks.withLock(name))
continue
}
yield* replaceServer(name, updated).pipe(locks.withLock(name))
}
applied = servers
})
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
const reconnect = (integrationID: Integration.ID) =>
Effect.gen(function* () {
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
const match = Array.from(entries).find(([, entry]) => entry.integrationID === integrationID)
if (!match) return
const name = match[0]
yield* Effect.gen(function* () {
// add() or remove() may have replaced or deleted the entry while we waited for the lock.
const entry = runtime.get(name)
const entry = entries.get(name)
if (!entry || entry.integrationID !== integrationID) return
if (entry.status.status === "disabled") return
yield* stopServer(name, entry)
@@ -658,33 +653,55 @@ export const layer = (options?: Options) =>
Effect.ignore,
),
)
yield* bus.subscribe(Event.Updated).pipe(
Stream.runForEach(() =>
reloadConfig().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
// Close the gap between the initial snapshot and the live subscription becoming active.
yield* reloadConfig()
const state = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
Array.from(overrides).flatMap(([name, config]) =>
config === false ? [] : [[name, cloneConfig(config)] as const],
),
),
removed: new Set(
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
get: (server) => draft.servers.get(ServerName.make(server)),
set: (server, serverConfig) => {
const name = ServerName.make(server)
if (draft.removed.has(name)) return
draft.servers.set(name, cloneConfig(serverConfig))
},
update: (server, update) => {
const current = draft.servers.get(ServerName.make(server))
if (!current) return
update(current)
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
finalize: reconcile,
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
const whenAllReady = Effect.suspend(() =>
Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
Effect.forEach(Array.from(entries.values()), (entry) => Deferred.await(entry.startup), {
concurrency: "unbounded",
discard: true,
}),
)
return Service.of({
transform: state.transform,
reload: state.reload,
servers: Effect.fn("MCP.servers")(function* () {
return Array.from(runtime)
return Array.from(entries)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([name, entry]) => new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }))
}),
add: Effect.fn("MCP.add")(function* (server, config) {
const name = ServerName.make(server)
yield* replaceServer(name, { ...config, timeout: { ...configState.timeout, ...config.timeout } }).pipe(
locks.withLock(name),
)
overrides.set(name, config)
yield* state.reload()
}),
connect: Effect.fn("MCP.connect")(function* (server) {
const name = ServerName.make(server)
@@ -705,14 +722,13 @@ export const layer = (options?: Options) =>
}),
remove: Effect.fn("MCP.remove")(function* (server) {
const name = ServerName.make(server)
yield* Effect.gen(function* () {
yield* requireServer(name)
yield* removeServer(name)
}).pipe(locks.withLock(name))
yield* requireServer(name)
overrides.set(name, false)
yield* state.reload()
}),
tools: Effect.fn("MCP.tools")(function* () {
yield* whenAllReady
return Array.from(runtime.values())
return Array.from(entries.values())
.flatMap((entry) => entry.tools ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}),
@@ -742,7 +758,7 @@ export const layer = (options?: Options) =>
}),
instructions: Effect.fn("MCP.instructions")(function* () {
yield* whenAllReady
return Array.from(runtime)
return Array.from(entries)
.flatMap(([server, entry]) => {
const instructions = entry.client?.instructions
if (!instructions) return []
@@ -751,7 +767,7 @@ export const layer = (options?: Options) =>
.toSorted((a, b) => a.server.localeCompare(b.server))
}),
prompts: Effect.fn("MCP.prompts")(function* () {
return Array.from(runtime.values())
return Array.from(entries.values())
.flatMap((entry) => entry.prompts ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}),
@@ -774,7 +790,7 @@ export const layer = (options?: Options) =>
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady
const catalogs = yield* Effect.forEach(
Array.from(runtime),
Array.from(entries),
([name, entry]) => {
if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
return Effect.all(
@@ -831,7 +847,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Config.node, Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
deps: [Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
})
}
+2
View File
@@ -11,6 +11,7 @@ import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { MCP } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
@@ -154,6 +155,7 @@ export const node = makeLocationNode({
Catalog.node,
Command.node,
Integration.node,
MCP.node,
Location.node,
Reference.node,
Skill.node,
+41
View File
@@ -4,6 +4,7 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -15,6 +16,7 @@ import { Bus } from "../bus.js"
import { Integration } from "../integration.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
@@ -34,6 +36,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
const commands = yield* Command.Service
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const mcp = yield* MCP.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const skill = yield* Skill.Service
@@ -269,6 +272,44 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}),
},
mcp: {
list: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
return response(mcp.servers())
},
add: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
return mcp.add(input.server, input.config)
},
remove: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
return mcp.remove(input.server)
},
connect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
return mcp.connect(input.server)
},
disconnect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
return mcp.disconnect(input.server)
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((draft) => {
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
}),
},
plugin: {
list: () => response(plugin.list()),
},
+8
View File
@@ -13,6 +13,7 @@ import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
@@ -34,6 +35,7 @@ import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { MCP } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
@@ -63,6 +65,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -94,6 +97,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -131,6 +135,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -175,6 +180,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -195,6 +201,8 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
@@ -0,0 +1,26 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode-exclusion",
effect: Effect.fn(function* (ctx) {
yield* ctx.mcp.transform((draft) => {
for (const [, server] of draft.list()) {
if (server.codemode !== undefined) continue
if (server.type === "local") {
if (server.command[0] === "executor" && server.command[1] === "mcp") server.codemode = false
continue
}
if (!URL.canParse(server.url)) continue
const url = new URL(server.url)
const endpoint = `${url.origin}${url.pathname.replace(/\/+$/, "")}`
if (urls.some((pattern) => pattern.test(endpoint))) server.codemode = false
}
})
}),
})
+45
View File
@@ -2,10 +2,12 @@ export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { MCP } from "../mcp/index.js"
import { Session } from "../session.js"
export interface Interface {
@@ -19,6 +21,8 @@ export interface Interface {
| "command"
| "rename"
| "resume"
| "switchAgent"
| "switchModel"
| "interrupt"
| "synthetic"
| "wait"
@@ -30,6 +34,15 @@ export interface Interface {
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Agent.Info[] }>
}
readonly mcp: {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
}
}
}
@@ -64,6 +77,8 @@ export const layerWithCell = (cell: Cell) =>
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
@@ -79,6 +94,13 @@ export const layerWithCell = (cell: Cell) =>
agent: {
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
},
mcp: {
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
},
},
}),
)
@@ -108,6 +130,29 @@ export const providerLayerWithCell = (cell: Cell) =>
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
},
mcp: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* MCP.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
add: (ref, server, config) =>
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
remove: (ref, server) =>
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
connect: (ref, server) =>
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
disconnect: (ref, server) =>
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
},
},
}
cell.runtime = runtime
+92 -25
View File
@@ -14,7 +14,7 @@ export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const backgroundStarted = (sessionID: SessionSchema.ID) =>
[
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
`The subagent is working in the background (sessionID: ${sessionID}). You will be notified automatically when it finishes.`,
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
@@ -23,6 +23,10 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
}),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
@@ -36,7 +40,8 @@ export const Output = Schema.Struct({
})
export const description = [
"Spawns an agent in a child session to work on the specified task.",
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
"The output includes a sessionID you can pass back later to continue that specific conversation with the subagent.",
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
@@ -50,6 +55,9 @@ export const Plugin = {
const config = yield* Config.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
// One completion observer per job generation. Keyed by child plus start time so a fresh
// continuation job is observable even while a settled generation's observer is finalizing.
const notifications = new Set<string>()
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
@@ -77,7 +85,7 @@ export const Plugin = {
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
@@ -88,7 +96,11 @@ export const Plugin = {
childID: SessionSchema.ID,
agent: string,
description: string,
startedAt: number,
) {
const key = `${childID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
yield* runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
@@ -106,6 +118,7 @@ export const Plugin = {
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
})
@@ -163,33 +176,74 @@ export const Plugin = {
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const child = yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
const existing =
input.sessionID === undefined
? undefined
: yield* runtime.session
.get(input.sessionID)
.pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Subagent session not found: ${input.sessionID}`, error }),
),
)
if (existing !== undefined && existing.parentID !== context.sessionID)
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
.pipe(
// Continuing with a different agent switches the child, mirroring create semantics
// where the agent's configured model wins over the inherited one.
if (existing !== undefined && existing.agent !== agent.id) {
yield* runtime.session.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
Effect.andThen(
agent.model === undefined
? Effect.void
: runtime.session.switchModel({ sessionID: existing.id, model: agent.model }),
),
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
(error) =>
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
),
)
}
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const child =
existing ??
(yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
))
const background = input.background === true
yield* context.progress({ sessionID: child.id, status: "running" })
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({
// Standard prompt admission outside the job: Job.start joining a running child skips
// its run effect, and the default wake starts an idle child or steers a running one.
yield* runtime.session
.prompt({
sessionID: child.id,
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
resume: false,
text:
existing === undefined
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
: input.prompt,
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Failed to prompt subagent: ${child.id}`, error }),
),
)
const run = Effect.gen(function* () {
yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
@@ -204,7 +258,7 @@ export const Plugin = {
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description, info.started_at)
return {
sessionID: child.id,
status: "running" as const,
@@ -220,21 +274,34 @@ export const Plugin = {
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
yield* notifyWhenDone(
context.sessionID,
child.id,
agent.name,
input.description,
result.info.started_at,
)
return {
sessionID: child.id,
status: "running" as const,
output: backgroundStarted(child.id),
}
}
// Failure surfaces keep the sessionID visible so the model can continue the child.
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return yield* new ToolFailure({
message: `Subagent failed (sessionID: ${child.id}): ${result.info.error ?? "unknown error"}`,
})
if (result?.info.status === "cancelled")
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
content:
output.status === "completed"
? `<subagent sessionID="${output.sessionID}" state="completed">\n${output.output}\n</subagent>`
: output.output,
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
@@ -1,42 +0,0 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Database } from "@opencode-ai/core/database/database"
import { isNetworkFilesystemType } from "@opencode-ai/core/database/network-filesystem"
import { Global } from "@opencode-ai/util/global"
import { sql } from "drizzle-orm"
import { Effect } from "effect"
test.each([
["SMB", 0x0000517b],
["9P", 0x01021997],
["FUSE", 0x65735546],
["NFS", 0x00006969],
["CIFS", 0xff534d42],
])("disables WAL on %s", (_name, type) => {
expect(isNetworkFilesystemType(type)).toBe(true)
})
test("keeps WAL on local filesystems", () => {
expect(isNetworkFilesystemType(0xef53)).toBe(false)
})
test("allows WAL to be disabled explicitly", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-database-"))
try {
const mode = await Effect.runPromise(
Effect.gen(function* () {
const database = yield* Database.Service
return yield* database.db.all<{ journal_mode: string }>(sql`PRAGMA journal_mode`)
}).pipe(
Effect.provide(Database.layer({ path: join(directory, "opencode.db"), wal: false })),
Effect.provideService(Global.Service, Global.make({ data: directory })),
Effect.scoped,
),
)
expect(mode).toEqual([{ journal_mode: "delete" }])
} finally {
await rm(directory, { recursive: true, force: true })
}
})
+2
View File
@@ -8,6 +8,8 @@ import { location } from "./location"
export const emptyMcpLayer = Layer.succeed(
MCP.Service,
MCP.Service.of({
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
servers: () => Effect.succeed([]),
add: () => Effect.die("unused mcp.add"),
connect: () => Effect.die("unused mcp.connect"),
+58
View File
@@ -16,6 +16,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -809,4 +810,61 @@ describe("LocationServiceMap", () => {
),
),
)
itWithSdk.live("lets public plugins mutate configured and runtime MCP servers", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const url = "https://example.com/mcp"
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({ mcp: { servers: { example: { type: "remote", url, disabled: true } } } }),
),
)
const observed: Record<string, boolean | undefined> = {}
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "mcp-codemode-policy",
effect: (ctx) =>
ctx.mcp
.transform((mcp) => {
for (const [name, server] of mcp.list()) {
if (server.type !== "remote" || new URL(server.url).hostname !== "example.com") continue
mcp.update(name, (current) => {
current.codemode = false
observed[name] = current.codemode
})
}
})
.pipe(Effect.asVoid),
}),
)
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* MCP.Service
yield* supervisor.flush
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
type: "remote",
url: "https://example.com/dynamic",
disabled: true,
})
expect(observed.dynamic).toBe(false)
expect((yield* mcp.servers()).map((server) => String(server.name))).toEqual(["dynamic", "example"])
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
}),
),
),
)
})
+80 -30
View File
@@ -14,7 +14,9 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Config } from "@opencode-ai/core/config"
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -174,11 +176,18 @@ function resourceMcpLayer(
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
environment?: Layer.Layer<Environment.Service>
published?: string[]
},
) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer(options).pipe(
return Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* ConfigMCPPlugin.register(bus.subscribe())
}),
).pipe(
Layer.provideMerge(MCP.layer(options)),
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
@@ -215,6 +224,7 @@ function resourceMcpLayer(
type: definition.type,
data,
} as Payload<typeof definition>
overrides?.published?.push(event.type)
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
},
@@ -912,6 +922,7 @@ test("loads and reads MCP resources", async () => {
})
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
const published: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
@@ -919,6 +930,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
const service = yield* MCP.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(published).toContain(McpEvent.StatusChanged.type)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
yield* service.add(
@@ -972,6 +984,9 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
disabled: true,
}),
undefined,
undefined,
{ published },
),
),
)
@@ -980,6 +995,70 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "original" },
oauth: false,
disabled: true,
})
yield* service.add("dynamic", config)
const transformed = yield* service.transform((draft) =>
draft.update("dynamic", (server) => {
if (server.type === "remote") server.headers = { Authorization: "transformed" }
}),
)
let observed: string | undefined
yield* service.transform((draft) => {
const server = draft.get("dynamic")
observed = server?.type === "remote" ? server.headers?.Authorization : undefined
})
expect(observed).toBe("transformed")
expect(config.headers?.Authorization).toBe("original")
yield* transformed.dispose
expect(observed).toBe("original")
}).pipe(
Effect.provide(
resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })),
),
),
),
)
})
test("isolates nested configured MCP mutations and reconciles them", async () => {
const published: string[] = []
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "original" },
oauth: false,
disabled: true,
})
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
yield* service.transform((draft) =>
draft.update("resources", (server) => {
if (server.type === "remote") server.headers = { Authorization: "transformed" }
}),
)
expect(config.headers?.Authorization).toBe("original")
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(2)
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { published }))),
),
)
})
test("reconciles only changed MCP server config", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1070,35 +1149,6 @@ test("reconciles only changed MCP server config", async () => {
)
})
test("reconciles MCP config changed during startup", async () => {
const server = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
let reads = 0
const entries = () =>
Effect.sync(() => {
reads += 1
return [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
servers: reads === 1 ? { initial: server } : { initial: server, added: server },
}),
}),
}),
]
})
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
expect((yield* service.servers()).map((item) => String(item.name))).toEqual(["added", "initial"])
expect(reads).toBeGreaterThanOrEqual(2)
}).pipe(Effect.provide(resourceMcpLayer(server, undefined, undefined, { entries }))),
),
)
})
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(

Some files were not shown because too many files have changed in this diff Show More