diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9d29724d86a4..c1ec3388f481 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -1,6 +1,10 @@ name: "Setup Bun" description: "Setup Bun with caching and install dependencies" inputs: + node-version: + description: "Node.js version used by dependency install scripts" + required: false + default: "24" install-flags: description: "Additional flags to pass to 'bun install'" required: false @@ -13,7 +17,7 @@ runs: - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "24" + node-version: ${{ inputs.node-version }} - name: Get baseline download URL id: bun-url diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e49c96264ca..f723fddc9464 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - dev + - production pull_request: workflow_dispatch: @@ -31,7 +32,7 @@ jobs: host: blacksmith-4vcpu-ubuntu-2404 - name: windows host: blacksmith-4vcpu-windows-2025 - runs-on: ${{ matrix.settings.host }} + runs-on: ${{ github.repository == 'anomalyco/opencode' && matrix.settings.host || (matrix.settings.name == 'windows' && 'windows-latest' || 'ubuntu-latest') }} defaults: run: shell: bash @@ -54,6 +55,10 @@ jobs: git config --global user.email "bot@opencode.ai" git config --global user.name "opencode" + - name: Install ripgrep + if: runner.os == 'Windows' + run: choco install ripgrep --version 15.1.0 --yes --no-progress + - name: Cache Turbo uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -65,7 +70,7 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: GITHUB_ACTIONS=false bun turbo test + run: GITHUB_ACTIONS=false bun turbo test --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || '2' }} env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} @@ -89,9 +94,10 @@ jobs: host: blacksmith-4vcpu-ubuntu-2404 - name: windows host: blacksmith-4vcpu-windows-2025 - runs-on: ${{ matrix.settings.host }} + runs-on: ${{ github.repository == 'anomalyco/opencode' && matrix.settings.host || (matrix.settings.name == 'windows' && 'windows-latest' || 'ubuntu-latest') }} env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers + PLAYWRIGHT_WORKERS: ${{ github.repository == 'anomalyco/opencode' && '5' || '2' }} defaults: run: shell: bash @@ -101,15 +107,12 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - name: Setup Bun + uses: ./.github/actions/setup-bun with: # Playwright 1.59 hangs while extracting Chromium with Node 24.16. node-version: "24.15" - - name: Setup Bun - uses: ./.github/actions/setup-bun - - name: Read Playwright version id: playwright-version run: | diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index fc9a52797c1d..ef65449f8989 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -2,14 +2,14 @@ name: typecheck on: push: - branches: [dev] + branches: [dev, production] pull_request: - branches: [dev] + branches: [dev, production] workflow_dispatch: jobs: typecheck: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -18,4 +18,8 @@ jobs: uses: ./.github/actions/setup-bun - name: Run typecheck - run: bun typecheck + run: bun typecheck --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || '2' }} + + - name: Typecheck browser regression fixtures + working-directory: packages/app + run: bun run typecheck:e2e diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 11aed2d6ed07..402765b65cb5 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -2,11 +2,11 @@ name: Sync Upstream on: schedule: - - cron: '0 6 * * *' + - cron: "0 6 * * *" workflow_dispatch: inputs: force: - description: 'Force merge even if already up-to-date' + description: "Force merge even if already up-to-date" type: boolean default: false @@ -91,6 +91,32 @@ jobs: } >> "$GITHUB_OUTPUT" fi + # GITHUB_TOKEN pushes do not trigger the normal push workflows. Gate here. + - name: Setup validation tools + if: steps.merge.outputs.status == 'merged' + uses: ./.github/actions/setup-bun + + - name: Validate merged types and unit tests + if: steps.merge.outputs.status == 'merged' + run: | + bun turbo typecheck --concurrency=2 + bun --cwd packages/app run typecheck:e2e + GITHUB_ACTIONS=false bun turbo test --concurrency=2 + bun --cwd packages/client run check:generated + bun --cwd packages/opencode run test:httpapi + + - name: Install browser test dependencies + if: steps.merge.outputs.status == 'merged' + working-directory: packages/app + run: bunx playwright install --with-deps chromium firefox + + - name: Validate merged browser behavior + if: steps.merge.outputs.status == 'merged' + working-directory: packages/app + env: + CI: true + run: bun run test:e2e:local + - name: Push merged changes if: steps.merge.outputs.status == 'merged' shell: bash diff --git a/bun.lock b/bun.lock index 54a959bbdc0e..bf1936aff331 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -98,7 +98,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.27", + "version": "1.18.29", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -146,7 +146,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -160,7 +160,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -197,7 +197,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -224,7 +224,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -247,7 +247,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -271,7 +271,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -291,7 +291,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.27", + "version": "1.18.29", "bin": { "opencode": "./bin/opencode", }, @@ -389,7 +389,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -443,7 +443,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -457,7 +457,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "effect": "catalog:", }, @@ -469,7 +469,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -501,7 +501,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -517,7 +517,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -548,7 +548,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -567,7 +567,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.27", + "version": "1.18.29", "bin": { "opencode": "./bin/opencode", }, @@ -699,7 +699,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -775,7 +775,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "cross-spawn": "catalog:", }, @@ -790,7 +790,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -805,7 +805,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -845,7 +845,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -858,7 +858,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -885,7 +885,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -904,7 +904,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -946,7 +946,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -974,7 +974,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1025,7 +1025,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/docs/fork/web-audit.md b/docs/fork/web-audit.md new file mode 100644 index 000000000000..9c4e4a9ea2fc --- /dev/null +++ b/docs/fork/web-audit.md @@ -0,0 +1,437 @@ +# Fork web compatibility audit + +Updated: 2026-09-05. + +## Release decision + +**Merge authorized with known follow-ups.** On 2026-09-05 the repository owner +explicitly requested committing and pushing every current improvement and merging +production PR #7 and development PR #8, with remaining issues handled in follow-up +PRs. This supersedes the earlier draft/release hold. It does not mean the full +browser suite or hosted CI is green. The checkpoint below records the evidence +and unresolved work without weakening or skipping failing tests. + +The owner also confirmed that the CPU was throttled during the slower benchmark. +Those timings are not comparable to the earlier unthrottled baseline and are not +established as a code-induced performance regression. + +Integrated upstream remains `70b4ca8c181e4c1ac6d8993b86249d824487ec65`; +newer upstream work is explicitly tracked below. No finite audit establishes that +the entire fork is bug-free. + +The deployed service on ports 4096/14096 and its binary were not changed. Tests +used separate worktrees, loopback servers, synthetic sessions and isolated XDG +data. Real-server smoke checks were unauthenticated; they do not establish +Basic-auth deployment parity. The replacement transport has a separate unit +test asserting Basic-auth headers with dummy credentials. + +## Comparison boundary + +| Ref | Commit | +| ------------------------------------ | ------------------------------------------ | +| Fork production baseline | `c94eb6133eca258cb06cc30d159aae7a76519d1b` | +| Fork development baseline | `722450048f` | +| Initial upstream production | `20a7743876` | +| Initial upstream development | `79903a4cf7` | +| Upstream integrated in both branches | `70b4ca8c181e4c1ac6d8993b86249d824487ec65` | + +The initial production delta was 510 files, 38,512 additions / 7,297 deletions. +The latest upstream merge did not change app/session-ui runtime source relative +to the initial upstream production snapshot. It did bring release, provider and +console changes, so both integration branches received full unit/type validation. + +Fork-heavy boundaries remain the current-session model/reducer, timeline +presentation, prompt submit path, session queue, model persistence and shared +message-part rendering. These are intentional features, not candidates for +wholesale upstream replacement. App code still composes a pinned promise client, +a separate pinned current-session client, generated legacy SDK, and compatibility +stores. Regenerating the workspace client alone does not update the pinned clients. + +### Functional comparison + +Pristine latest upstream passed all 10 selected Chromium tests: reasoning-selector +visibility, eight reasoning/timeline profiles, and review-state persistence. +The fork passes those corresponding contracts with its native-session fixtures, +plus its additional queue/replacement/pending/model-selection regressions. + +An attempted identical-fixture comparison was invalid: fork-only payload fields +failed upstream schema validation and the upstream UI did not subscribe to the +fork's native session-event endpoint. Those failures are **not** counted as +upstream product bugs. Compare observable contracts using each protocol's valid +fixtures; do not silently rewrite either runtime to fit the other's mocks. + +## Reproduced defects and fixes + +### 1. Admitted steering disappeared after reload + +Admission is durable but transcript promotion occurs later at a runner boundary. +The web snapshot lacked pending admissions; replay starts at the snapshot's +watermark, so already-admitted input could not be reconstructed from later events. + +The message endpoint now includes an optional pending-user projection, selected +from unpromoted, undiscarded steering rows. Queue inputs remain separate. The +current-session model hydrates that projection and ignores stale pending snapshots +that would resurrect discarded input. Neither this query nor stop promotes or +resumes input. New mapping logic is isolated in `pending-inputs.ts`. + +Evidence: database selection/interrupt tests, reducer stale-snapshot test, model +hydration test, cross-browser reload test, and an isolated real-server interrupt +followed by browser reload. Admitted text remained visible and Stop was absent +when execution was idle. + +### 2. Rollback editing disagreed with upstream revert semantics + +Upstream revert retains its boundary message. The fork web editor hides that +message and means to replace it. Pending admissions also are not projected +message boundaries. Passing an extra flag to the pinned promise client was not +sufficient: its encoder omitted the unknown field. + +Inclusive replacement is now explicit and opt-in. Default upstream behavior is +unchanged. Staging captures an admission cutoff without deleting input. Commit +removes the selected original and subsequent pre-cutoff steering, preserves +explicit pending queues, and preserves the replacement admitted after staging. +The runner still commits before promoting new input. The focused persistence +logic lives in `revert-replacement.ts`; `server-revert.ts` bridges only the opt-in +request through the generated SDK. Clients were regenerated by repository scripts. + +The current-session projection is also updated optimistically and refreshed after +commit. Rollback no longer continues after an interruption error; it restores the +draft and shows a request-failed notification. Both composer implementations send +the explicit replacement flag on Enter. + +Evidence: 106 runner/projector/replacement tests passed, including default upstream +boundary retention. The live API check staged a pending original, admitted an +edited replacement, committed, and returned only the edited pending message. +Cross-browser tests cover Enter replacement, retained queue, and interruption +failure. Multi-client concurrent stage/resume remains a coverage gap. + +### 3. Review metadata and diff routes drifted across clients + +Current-session protocol detection did not imply support for `/api/vcs`. The +isolated server returned UI HTML for that unsupported path, and bootstrap skipped +legacy branch metadata on V2. The fix reuses the existing VCS compatibility adapter +and loads branch metadata from the supported endpoint. No new parallel VCS +implementation was introduced. + +Evidence: transport tests assert `/vcs/diff`, working-to-git mode translation, +directory placement and returned content; bootstrap tests assert branch metadata; +review persistence passes in both browsers. Review-line fixtures were corrected +to observe the actual compatibility endpoint rather than a nonexistent V2 route. + +### 4. Switching models reset timeline scroll + +The recent-model resource used a suspending read. A selection changed recent +models and briefly detached the session subtree, resetting the same scroll node +to zero. Browser instrumentation confirmed removal/reinsertion under `main`, +rather than a new session or new scroll node. The resource code is shared with +upstream; this is not evidence that every upstream UI configuration exhibits it. + +A one-line read of the resource's latest value prevents that suspension without +restructuring the layout. All six Chromium/Firefox model-switch scroll regressions +pass, including variant/no-variant changes and unchanged composer dimensions. + +### 5. Test and CI drift obscured regressions + +Mocks dropped model variants, assigned obsolete release dates, ignored configured +agents, lacked context/pending snapshots, and left rename responses non-mutating. +Several tests imported a removed pagination helper or asserted legacy part IDs +against normalized native rendering. Other fixtures hardcoded the local server +port, defeating isolated-port runs. Corrected fixtures retain semantic assertions; +no failing tests were skipped or weakened to force a green result. + +Three HTTP API exerciser assertions expected raw project values while the declared +Effect API uses located envelopes. Assertions now validate location/project and +the nested data explicitly. Coverage/auth/effect runs each passed 236 scenarios, +with no missing or skipped route scenarios. + +Fork CI previously selected upstream-only Blacksmith runners. It now selects +GitHub-hosted runners on the fork, checks production as well as dev, and typechecks +the addressed browser fixtures. Scheduled upstream sync now runs validation before +pushing because its GITHUB_TOKEN push does not trigger normal push workflows. +Actionlint syntax validation passed; optional shellcheck still reports existing +SC2129 style findings in the sync script, not new syntax errors. + +### 6. Firefox could not open the tab context menu with Shift+F10 + +A focused tab received both keydown events but no native `contextmenu` event. +The Kobalte trigger handles `contextmenu` and pointer events, not these keyboard +keys. The tab now explicitly routes Shift+F10 and ContextMenu to that existing +trigger, anchored below the focused tab. The helper is isolated in the existing +fork tab-gesture module; editing/dragging guards stay at the integration point. +Preventing the default avoids duplicate native synthesis on other browsers. + +Evidence: repeated Firefox failure before the fix, keyboard-event instrumentation, +three new unit cases, and all 16 rename tests passing in Chromium/Firefox. The +keyboard assertion was not replaced with a pointer click or skipped. + +### 7. Context circle counted messages hidden by staged rollback + +The message prop fix resolved the always-zero circle, but a second discrepancy +remained: the detail screen selects context through the staged revert boundary; +the circle used the full context snapshot. Staging does not delete that snapshot. +The circle now reuses `selectSessionContextMessages`, the same existing helper as +the detail screen. This is one import and one targeted call-site integration. + +Evidence: a schema-validated browser fixture records 50,000 of 100,000 tokens before +rollback and 25,000 after rollback. The rollback circle failed at 50% before this +fix. All four Chromium/Firefox cases now assert the SVG's numeric progress, +tooltip token count, and detail-screen total/percentage against explicit values. + +## Validation record + +- Both latest-upstream integration branches: **30/30 typecheck tasks and 10/10 + full unit-test tasks passed**. Each core suite: 1,142 tests. Each opencode suite: + 3,650 tests. Latest app suite: 793 tests. Generated-client check passed. +- Focused fork browser run: **32/32 passed** across Chromium and Firefox; separate + model-switch scroll run: **6/6 passed**. +- Initial full browser run: **135 passed, 96 failed, 3 pre-existing skips**. + Latest full rerun: **178 passed, 55 failed, 3 pre-existing skips** (236 cases, + 6.3 minutes). This rerun includes keyboard/remote-fixture fixes and precedes the + final context-selection fix; the latter separately passed all four new cases. + Neither run is an all-green result. +- Rename tests: **16/16 passed** across browsers after correcting native rename + fixtures and keyboard menu activation. Remote settings/auto-accept tests: + **4/4 passed**, including unfocused parent/child sessions on a different server. + The actual current request query is `location[directory]`, not `directory`; + current replies are session-addressed and use `{ reply: "once" }`. +- CI is not green. Windows development jobs failed before tests during Bun 1.3.14 + patch installation (`ENOTEMPTY` for patched `@ai-sdk/openai-compatible`), even + with no restored cache. Linux development unit CI hit subprocess timeouts in + `run-process.test.ts`; local full runs passed. Production typechecking was + cancelled, not a demonstrated compiler error. These require investigation and + successful reruns; no timeouts or assertions have been weakened. +- Manual browser against isolated source backend: edited pending message visible, + original absent, no Stop while idle; Muse Spark 1.3 Free offered Default, + Minimal, Low, Medium, High and Xhigh reasoning choices. No paid inference used. +- Production tab-switch benchmark: both before/after runs passed. V2 median stable + times, milliseconds: + + | Scenario | Before | After | + | ------------------- | -----: | ----: | + | Review closed, cold | 136.2 | 142.8 | + | Review closed, hot | 103.7 | 115.1 | + | Review open, cold | 120.6 | 120.4 | + | Review open, hot | 112.6 | 113.4 | + + Five local samples per scenario, not a statistical performance guarantee. The + final after run includes latest integrated upstream, the recent-model fix, and + the context-selection fix. These samples include concurrent local browser work; + the increases are not established as code-induced regressions or dismissed as + harmless. Repeat isolated performance measurements before release. + No wrong-destination or review-host replacement samples were observed. + +## Historical full-suite failure inventory (before checkpoint) + +These are failing test cases, not 55 confirmed product bugs. Several fixtures still +send mutation arrays as one SSE event or assert obsolete part IDs. Repair invalid +fixtures without weakening observable behavior assertions; investigate failures +that remain against valid data. + +| Area | Cases | +| ---------------------------------- | ----: | +| Native timeline transport | 14 | +| Timeline projection | 6 | +| Smoke pagination/timeline | 6 | +| Collapse state | 4 | +| History-root transitions | 4 | +| Subagent navigation | 4 | +| Context resize | 3 | +| Lifecycle/retry | 3 | +| Request docks | 2 | +| Reducer projection | 2 | +| Shell outline | 2 | +| Todo navigation | 2 | +| New-project model-selection story | 2 | +| Review/terminal stacking (Firefox) | 1 | + +## Historical work plan (superseded by checkpoint backlog) + +1. [x] Correct remote settings fixtures and verify cross-server auto-accept, + including unfocused parent/child sessions. This was fixture protocol drift, + not evidence of a runtime permissions defect. +2. [x] Resolve Firefox keyboard context-menu opening while retaining the keyboard + assertion. Rename, tab close and focus restoration pass on both browsers. +3. Migrate remaining timeline transport fixtures to individual native events and + native normalized part identities. Audit actual render behavior for collapse, + retry, context resize, history root, comments, attachments and subagent cards. +4. Finish request-dock, todo, smoke pagination and new-project/model user-story + validation. All configured test ports must be honored. Extend e2e typechecking + to the remaining regression files; current coverage is intentionally enumerated, + not a claim that every e2e file typechecks. +5. Audit keyboard undo/redo interruption-error handling and compatibility-store + reads. Main rollback is fixed; other entrypoints must receive equivalent tests. + Add multi-client replacement/cutoff tests. Basic and staged-rollback context + usage browser parity is now covered; live usage updates remain to be audited. +6. Run the complete browser suite and final benchmark on both integration heads; + inspect Linux and Windows CI. Update the PR validation record with final results. +7. Re-fetch upstream, validate any additional commits, then merge both PRs and + verify upstream ancestry on dev and production. + Do not deploy or reinstall the user's running service as an incidental step. + +## Mergeability rule + +Keep fork behavior behind focused modules and optional protocol fields. Prefer +small call-site adapters over editing vendored client archives or broadly rewriting +upstream UI. Preserve upstream defaults. Every future sync must validate these +boundaries, not merely resolve textual merge conflicts. + +## Saved-work checkpoint — 2026-09-05 + +This is a reviewed **checkpoint with known unresolved issues**. The owner has +authorized merging it and deferring the remaining work to follow-up PRs. No runtime features were removed to make the upstream diff smaller. +The changes use existing adapters and optional call-site callbacks; they do not +replace the session architecture. The user's live service and installed binary +remain untouched. + +### Additional underlying fixes saved + +- **Stop before a provider turn:** interruption could succeed before any assistant + step existed, leaving no step-ended event to clear the composer's busy state. + After a successful interrupt, both composers now refresh the current session. + Queue draining is paused first. Admitted steering is neither deleted nor + resubmitted. The new browser case failed before this change and passes after it, + including reload. The submit test asserts pause → interrupt → refresh ordering. +- **Native user projection:** compatibility conversion now preserves native file + URIs, agent mentions and original typed payload parts, including synthetic + comment context. Native rich prompts no longer depend on lossy legacy fields. +- **Child-task navigation:** native task metadata uses `sessionID`, while legacy + cards read `sessionId`. The adapter supplies the alias without replacing an + existing legacy value, and reads native structured metadata. Child headings + resolve descriptions from the parent's cached parts, not the child-only current + message accessor. +- **Retry display:** the current-to-compatibility status bridge now includes retry + metadata and updates attempts even when the status tag has not changed. Native + retry → recovery → idle browser coverage asserts both attempts. +- **Supported shared endpoints:** V2 session detection no longer forces empty + todos or path metadata. The existing todo endpoint remains authoritative for + persisted tasks; live updates and forced refresh retain their existing behavior. + Path lookup retains an empty fallback only when unavailable on a V2 server. +- **Keyboard rollback integration:** Undo/Redo now receive current user messages, + including pending steering, and delegate to the same rollback/restore mutations + as the timeline. Completion is guarded against session navigation. Undo reaches + the correct pending draft in the new regression; the full Redo interaction is + still blocked by the failure below and is not claimed fixed end-to-end. +- **Provider discovery:** onboarding choices come from the integration catalogue, + separately from the connected-model catalogue. Missing/offline responses are + tolerated; models and connected/default selections are not invented. This fixes + the discovery boundary but exposes a remaining connection-dialog hydration + defect; the whole onboarding flow is still incomplete. +- **Replacement consistency:** two independent current-session clients converge + after a committed replacement. Persistence tests cover a steer exactly at the + admission cutoff, a later replacement, explicit queues, and an unrelated session. + +### Test-fixture and CI improvements saved + +Native timeline fixtures now send individual events with the correct aggregate +ID, durable sequence, normalized text/reasoning IDs and current endpoint envelopes. +Reconnect tests assert the durable `after` cursor. Pagination distinguishes the +current 100-message page from compatibility hydration. Browser assertions still +check behavior, ordering, geometry, caret restoration and error absence; failing +cases were not skipped or given relaxed assertions. Expanded e2e typechecking is +explicitly enumerated, not comprehensive coverage of every Playwright file. + +The shared Bun setup action now honors its caller's Node version, so the existing +Playwright Node 24.15 pin is no longer silently overwritten. Fork CI uses lower +concurrency; upstream workflow concurrency is retained. CLI tests cap concurrent +subprocess tests at two without increasing their timeouts. Windows fixtures use +portable paths and shell syntax, ripgrep is preinstalled, and Turbo forwards the +existing isolated-home/file-watcher environment into core tests. + +The earlier GitHub failures were **not established as quota exhaustion**: + +- Typecheck jobs exited 137 or were cancelled without TypeScript diagnostics. + Resource pressure is plausible, but OOM was not proven. +- Linux subprocess timeout failures reproduced locally with cold concurrent + compilation. Pinned Bun 1.3.14 with concurrency two passed all 14 CLI cases with + the same deadlines (49 assertions). +- Windows showed real portability/download issues as well as a distinct patched + Bun dependency installation `ENOTEMPTY` failure. The portability changes are + tested on Linux; a clean Windows pass is still required. A stock Windows smoke + VM was used for investigation, but its attempted install is not a passing result. + +### Checkpoint verification + +Commands below run from the named package directory, never root `bun test`. + +| Validation | Observed result | +| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| App `bun run test` | 800 unit + 51 browser-environment unit tests passed; zero failures | +| App `bun typecheck` and `bun run typecheck:e2e` | Both passed | +| Core `bun test test/command.test.ts test/session-prompt.test.ts test/session-replacement.test.ts` | 38 passed, 100 assertions | +| Timeline fixture unit test | 4 passed, 8 assertions | +| Workflow `actionlint` (test and typecheck) | Passed | +| Full pinned-Bun Linux Turbo unit run before the final focused additions | 10/10 tasks passed, uncached, 14m43s; core 1,142 tests; opencode 3,627 pass / 22 skip / 1 todo | +| Seven timeline projection/lifecycle/geometry specs, Chromium + Firefox | 43 passed; 3 existing Firefox CDP skips | +| Stop, pending reload, rollback and native transport focused run | 24 passed, 2 failed; only command Undo/Redo failed | +| Recorded, rolled-back and live context usage | 6 passed across Chromium + Firefox | +| Fresh request/todo/child/review/onboarding/smoke run | 18 passed, 8 failed across Chromium + Firefox | + +These are separate runs with some overlapping cases, not an aggregate full-suite +pass. The fresh 26-case run confirms request-dock caret, todo lifecycle, child +navigation, prepend anchoring and cold-tab paint behavior. Its eight failures are +listed below. A final full browser run on both exact branch heads remains follow-up work. + +### Actionable backlog for follow-up PRs + +| ID / priority | Evidence and likely boundary | Required next step / exit criterion | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| WEB-01 / P1 | New-project OpenCode Go discovery succeeds, but selecting it throws `Cannot read properties of undefined (reading 'name')` in `ProviderConnection` / `MethodSelection`, before the API-key field appears (both browsers). `provider()` assumes that a new hook instance already has either integration choices or a connected provider. | Make connection rendering safe during catalogue hydration without inventing connected models. Add a delayed-catalogue regression and complete key submission, refresh and model selection in both browsers. | +| WEB-02 / P1 | Command Undo selects/stages the pending message correctly; the subsequent Ctrl+P for Redo never produces a dialog textbox in either browser. No error page in the clean repro. | Separate command-dialog/focus teardown from a runtime hotkey defect; retain real keyboard activation. Require stage, clear, empty draft and restored single message assertions to pass. | +| WEB-03 / P2 | Review/terminal stacking times out waiting for a tree to have positive height in both browsers in the fresh run. Native lifecycle events are now used. | Inspect layout readiness and fixture review mode before changing runtime. Verify tree/terminal geometry, scrolling, detail refresh and remount invariants, without fixed sleep inflation. | +| WEB-04 / P2 | Cached-tab smoke reports one removed first-paint plain text `` in both browsers; latest-message and bottom placement assertions already pass. Probe currently tracks every descendant, including `HighlightedText` leaves rebuilt during hydration. | Establish whether a user-visible row/part remount occurs. Preserve semantic row identity and first-frame assertions; distinguish incidental leaf updates from structural replacement with a dedicated repro rather than blindly relaxing zero-removal. | +| WEB-05 / P2 | Full-history smoke reaches its final error audit, then fails: Chromium logs a current-context transport error; Firefox logs a global event-stream failure. No forbidden text or error toast was observed. | Determine mock coverage/reconnect or cancellation logging versus a real transport failure; preserve the no-console-errors contract and full ordering checks. | +| CI-01 / P1 | Windows patched dependency install failed with `ENOTEMPTY`; clean Windows validation is not established. | Reproduce using pinned Bun in the stock smoke VM or hosted runner, resolve install failures, then run affected core/MCP/ripgrep/LSP/CLI suites and Windows browser gates. Do not call this quota without evidence. | +| CI-02 / P1 | Both old PR heads have red GitHub checks. Local Linux full tests pass, but that does not establish hosted or Windows parity. | Inspect new-head jobs and logs. Require compiler, unit and browser failures to be fixed; document decent local equivalents only for demonstrated infrastructure/config/quota problems. | +| SYNC-01 / P1 | Latest fetched upstream dev and production are `e2894562f8ba943d72172d10b727c24d5f650c16`; integration branches contain `70b4ca8c181e4c1ac6d8993b86249d824487ec65`. The extra commit changes console usage normalization/tier configuration, not the web session fixes. | Merge this and any later upstream changes into both integration branches with affected console validation; recheck ancestry before merge. This checkpoint does not claim latest-upstream completion. | +| RELEASE-01 / P1 | Owner-authorized merge accepts the documented failures; Basic-auth deployed-browser parity and final exact-head full browser validation remain outstanding. | Complete the backlog in follow-up PRs and rerun both branches. Reinstall/deploy separately when explicitly requested. | + +### Reproduction commands + +From `packages/app`, against the task-owned mock-test Vite server (currently +14449, backend request origin 14999): + +```sh +PLAYWRIGHT_BASE_URL=http://127.0.0.1:14449 PLAYWRIGHT_PORT=14449 \ +PLAYWRIGHT_SERVER_PORT=14999 PLAYWRIGHT_WORKERS=2 bun run test:e2e \ + e2e/regression/session-rollback-queue.spec.ts \ + e2e/regression/review-terminal-stacked.spec.ts \ + e2e/user-story/model-selection-flow.spec.ts \ + e2e/smoke/session-timeline.spec.ts +``` + +The tests install synthetic API routes; these ports are not the deployed service. +If no task server is listening, use the repository Playwright configuration's own +server lifecycle on an unused port. Never restart the user's service for this. + +Production benchmark (builds its own temporary preview; run without other test +workloads): + +```sh +PLAYWRIGHT_PORT=14448 PLAYWRIGHT_WORKERS=1 bun run test:e2e \ + --config e2e/performance/playwright.config.ts --project=chromium \ + e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +``` + +### Checkpoint performance result — CPU-throttled comparison + +The final production-build benchmark passed its two structural test cases, but +**did not establish performance parity**. V2 median stable times (five samples per +scenario) increased relative to the earlier baseline: + +| Scenario | Earlier baseline (ms) | Checkpoint (ms) | +| ------------------- | --------------------: | --------------: | +| Review closed, cold | 136.2 | 406.0 | +| Review closed, hot | 103.7 | 314.0 | +| Review open, cold | 120.6 | 354.2 | +| Review open, hot | 112.6 | 324.0 | + +No wrong-destination or review-file-host replacement samples were observed. The +owner subsequently confirmed that CPU throttling caused the slower timings. +The focused browser run finished before benchmark sampling; its tail overlapped +the benchmark build startup. Because CPU conditions differed, these measurements +cannot establish either performance parity or a code-induced regression. + +**PERF-01 / P2 (follow-up):** rerun baseline and checkpoint alternately with matching +CPU throttling and machine load. Retain first-frame/latest-message and review-host +identity checks. Investigate code only if a slowdown reproduces under comparable +conditions; the throttled comparison is not a merge blocker. diff --git a/packages/app/e2e/performance/timeline-stability/fixture.test.ts b/packages/app/e2e/performance/timeline-stability/fixture.test.ts index 67a1cfb21466..20ad00441aee 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.test.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.test.ts @@ -51,10 +51,11 @@ describe("timeline fixture validation", () => { }) const second = event("session.next.retried", { timestamp: 2, - sessionID: "ses_timeline_stability", + sessionID: "ses_other_timeline", attempt: 2, error: { message: "retry", isRetryable: true }, }) + expect(second.durable?.aggregateID).toBe("ses_other_timeline") expect(first.id).toMatch(/^evt_timeline_\d{4}$/) expect(Number(second.id.slice(-4))).toBe(Number(first.id.slice(-4)) + 1) }) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 3b6041770ef0..5cd8a459a2e6 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -137,7 +137,9 @@ export async function setupTimeline( } await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() - await expectSessionTitle(page, title) + if (input.settings?.newLayoutDesigns === false) + await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible() + else await expectSessionTitle(page, title) await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) if (input.cpuRate && input.cpuRate > 1) { const devtools = await page.context().newCDPSession(page) @@ -219,7 +221,7 @@ export function event(type: EventType, data: TimelineEvent["data"]): TimelineEve ...(durable ? { durable: { - aggregateID: sessionID, + aggregateID: data.sessionID, seq: ++durableSequence, version: type === "session.next.step.ended" || type === "session.next.step.failed" ? 2 : 1, }, diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts index 8f57fef21c5d..5567ad5700f6 100644 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -44,3 +44,39 @@ test("applies message latency after a list response gate is released", async () expect(performance.now() - released).toBeGreaterThanOrEqual(20) expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) }) + +test("V2 fixtures preserve configured reasoning variants and custom agents", async () => { + let handler: ((route: Route) => Promise) | undefined + const page = { + route: (_url: string, callback: typeof handler) => { + handler = callback + return Promise.resolve() + }, + } as unknown as Page + await mockOpenCodeServer(page, { + directory: "/fixture", + project: {}, + sessions: [], + provider: { + all: [{ id: "test", models: { muse: { id: "muse", variants: { high: { reasoningEffort: "high" } } } } }], + connected: ["test"], + default: { providerID: "test", modelID: "muse" }, + }, + agents: [{ name: "reviewer", mode: "primary" }], + }) + const request = async (path: string) => { + let body = "" + await handler!({ + request: () => ({ url: () => `http://127.0.0.1:4096${path}`, method: () => "GET" }), + fulfill: (response: { body: string }) => { + body = response.body + return Promise.resolve() + }, + } as unknown as Route) + return JSON.parse(body) + } + expect((await request("/api/model")).data[0].variants).toEqual([ + { id: "high", settings: { reasoningEffort: "high" }, headers: {}, body: {} }, + ]) + expect((await request("/api/agent")).data[0].id).toBe("reviewer") +}) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index f09a2c7b63ae..0433043aef87 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { currentSession } from "../utils/mock-server" -const serverA = "http://127.0.0.1:4096" +const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const serverB = "http://127.0.0.1:4097" const sessionA = session("ses_server_a", "C:/server-a", "Server A session") const sessionB = session("ses_server_b", "/home/server-b", "Server B session") @@ -11,18 +11,18 @@ test("closing the active server's last tab opens the remaining server tab", asyn const requests: string[] = [] await mockServers(page, requests) await page.addInitScript( - ({ serverB, sessionA, sessionB }) => { + ({ serverA, serverB, sessionA, sessionB }) => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([ - { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, + { type: "session", server: serverA, sessionId: sessionA }, { type: "session", server: serverB, sessionId: sessionB }, ]), ) }, - { serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + { serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id }, ) const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts index 2cdb0b4a03b4..435d12fb66c1 100644 --- a/packages/app/e2e/regression/project-picker-recent-search.spec.ts +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test" import type { Page } from "@playwright/test" -import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { fixture, currentPageMessages } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" @@ -22,7 +22,7 @@ async function openProjectDialog(page: Page) { provider: fixture.provider, directory: fixture.directory, project: fixture.project, - pageMessages, + currentPageMessages, fileList: () => [], findFiles: () => [], }) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index b7e522a0ad0f..9aea55d4623f 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -1,9 +1,9 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page, type Route } from "@playwright/test" import { installSseTransport } from "../utils/sse-transport" -import { currentSession } from "../utils/mock-server" +import { currentCatalog, currentSession } from "../utils/mock-server" -const serverA = "http://127.0.0.1:4096" +const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const serverB = "http://127.0.0.1:4097" const directoryA = "C:/server-a" const directoryB = "/home/server-b" @@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => { .poll(() => permissionRequests.some((request) => { const url = new URL(request) - return url.origin === serverB && url.searchParams.get("directory") === directoryB + return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB }), ) .toBe(true) @@ -48,7 +48,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => const permissionResponses: PermissionResponse[] = [] const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server: serverA, - path: "/global/event", + path: "/api/event", retry: 20, }) await mockServers(page, permissionRequests, permissionResponses) @@ -68,7 +68,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .poll(() => permissionRequests.some((request) => { const url = new URL(request) - return url.origin === serverA && url.searchParams.get("directory") === directoryA + return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA }), ) .toBe(true) @@ -83,14 +83,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => directory: directoryA, payload: { id: "event-permission-background-a", - type: "permission.asked", + type: "permission.v2.asked", properties: { id: "permission-background-a", sessionID: sessionA.id, - permission: "bash", - patterns: ["git status"], + action: "bash", + resources: ["git status"], metadata: {}, - always: [], + save: [], }, }, }) @@ -100,10 +100,9 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, sessionID: sessionA.id, permissionID: "permission-background-a", - body: { response: "once" }, + body: { reply: "once" }, }, ]) @@ -111,14 +110,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => directory: directoryA, payload: { id: "event-permission-background-a-child", - type: "permission.asked", + type: "permission.v2.asked", properties: { id: "permission-background-a-child", sessionID: childSessionA.id, - permission: "bash", - patterns: ["git diff"], + action: "bash", + resources: ["git diff"], metadata: {}, - always: [], + save: [], }, }, }) @@ -128,17 +127,15 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, sessionID: sessionA.id, permissionID: "permission-background-a", - body: { response: "once" }, + body: { reply: "once" }, }, { origin: serverA, - directory: directoryA, sessionID: childSessionA.id, permissionID: "permission-background-a-child", - body: { response: "once" }, + body: { reply: "once" }, }, ]) }) @@ -169,8 +166,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR const remote = url.origin === serverB const directory = remote ? directoryB : directoryA const sessions = remote ? [sessionB] : [sessionA, childSessionA] - const requestDirectory = url.searchParams.get("directory") - const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + const requestDirectory = url.searchParams.get("location[directory]") + const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/) if (route.request().method() === "POST" && response) { permissionResponses.push({ origin: url.origin, @@ -179,16 +176,21 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR permissionID: response[2]!, body: route.request().postDataJSON(), }) - return json(route, true) + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") - return json(route, { data: [] }) - if (url.pathname === "/api/model/default") return json(route, { data: null }) - if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + const catalog = currentCatalog({ provider: provider(remote ? "server-b" : "server-a") }) + if (url.pathname === "/api/provider") return json(route, { data: catalog.providers }) + if (url.pathname === "/api/model") return json(route, { data: catalog.models }) + if (url.pathname === "/api/model/default") return json(route, { data: catalog.default }) + if (url.pathname === "/api/permission/request") { + permissionRequests.push(url.toString()) + return json(route, { location: { directory }, data: [] }) + } + if (["/api/agent", "/api/command", "/api/reference", "/api/question/request"].includes(url.pathname)) return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp/resource") @@ -206,7 +208,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR } if (url.pathname === "/api/project/current") return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) - if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session") + return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 2d9b1e234971..e3ce916c43aa 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { currentSession } from "../utils/mock-server" -const serverA = "http://127.0.0.1:4096" +const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const serverB = "http://127.0.0.1:4097" const sessionA = session("ses_server_a", "C:/server-a", "Server A session") const sessionB = session("ses_server_b", "/home/server-b", "Server B session") diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 566105e7646e..d72c69f483f4 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -146,10 +146,10 @@ async function openReview(page: Page) { const changes = page.getByRole("tab", { name: "Changes" }) const diffResponse = page.waitForResponse( (response) => - response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff", + response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/vcs/diff", ) await changes.click() - expect((await (await diffResponse).json()).data).toHaveLength(1) + expect(await (await diffResponse).json()).toHaveLength(1) await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/) const review = page.locator('[data-component="session-review"]') diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 57abee5b678a..ce944a892406 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -93,7 +93,11 @@ async function setup(page: Page) { route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + body: JSON.stringify( + new URL(route.request().url()).pathname.startsWith("/api/") + ? { location: { directory }, data: { branch: "feature", defaultBranch: "dev" } } + : { branch: "feature", default_branch: "dev" }, + ), }), ) await page.route("**/vcs/diff**", (route) => diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 79b564820e20..fdd66320b741 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/ReviewTerminalStacked" @@ -19,13 +20,16 @@ const branchDiffs = [ test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test.setTimeout(120_000) - const events: Array<{ directory: string; payload: Record }> = [] + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + path: `/api/session/${sessionID}/event`, + }) const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } let detailVersion = 1 let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v1", + protocol: "v2", directory, project: { id: projectID, @@ -58,9 +62,19 @@ test("keeps the review tree and terminal sized when both panels are open", async }, ], sessionStatus: () => sessionStatus, - pageMessages: () => ({ items: [] }), - events: () => events.splice(0, 1), - eventRetry: 16, + currentPageMessages: () => ({ + items: [ + { + id: "msg_review_user", + type: "user", + text: "Review changes", + files: [], + agents: [], + time: { created: 1700000000000 }, + }, + ], + throughSeq: 0, + }), }) await page.route(/\/vcs(?:\?.*)?$/, (route) => route.fulfill({ @@ -148,6 +162,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) + await transport.waitForConnection() await expect(page.locator("#review-panel")).toBeVisible() await expectTree(page, 8, "git-0.ts") @@ -159,8 +174,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expectStackGeometry(page) const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await treeViewport.hover() - await page.mouse.wheel(0, 100_000) + await treeViewport.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })) await expect .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) .toBeLessThanOrEqual(1) @@ -185,7 +199,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expect(preview).toContainText("after-1") detailVersion = 2 sessionStatus[sessionID] = { type: "busy" } - events.push(statusEvent("busy")) + await transport.send(statusEvent("busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() const refreshedDiff = page.waitForRequest((request) => { const url = new URL(request.url()) @@ -195,7 +209,7 @@ test("keeps the review tree and terminal sized when both panels are open", async ) }) sessionStatus[sessionID] = { type: "idle" } - events.push(statusEvent("idle")) + await transport.send(statusEvent("idle")) await refreshedDiff await expect(preview).toContainText("after-2") await selectMode(page, "Branch changes", "Git changes") @@ -288,8 +302,21 @@ function base64Encode(value: string) { function statusEvent(type: "busy" | "idle") { return { - directory, - payload: { type: "session.status", properties: { sessionID, status: { type } } }, + id: `evt_review_${type}`, + type: type === "busy" ? "session.next.step.started" : "session.next.step.ended", + durable: { aggregateID: sessionID, seq: type === "busy" ? 1 : 2, version: type === "busy" ? 1 : 2 }, + data: { + timestamp: 1700000001000, + sessionID, + assistantMessageID: "msg_review_assistant", + ...(type === "busy" + ? { agent: "build", model: { providerID: "opencode", id: "test" } } + : { + finish: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + }, } } diff --git a/packages/app/e2e/regression/session-context-usage.spec.ts b/packages/app/e2e/regression/session-context-usage.spec.ts new file mode 100644 index 000000000000..0461fc378597 --- /dev/null +++ b/packages/app/e2e/regression/session-context-usage.spec.ts @@ -0,0 +1,121 @@ +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Schema } from "effect" +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" +import { installSseTransport } from "../utils/sse-transport" +import { event, type TimelineEvent } from "../performance/timeline-stability/fixture" + +const directory = "C:/OpenCode/ContextUsageRegression" +const sessionID = "ses_context_usage" +const model = { providerID: "opencode", modelID: "test" } +const messages = [0, 1].flatMap((index) => [ + { + id: `msg_${index * 2 + 1}_user`, + type: "user" as const, + text: `Prompt ${index}`, + files: [], + agents: [], + time: { created: index * 2 + 1 }, + payload: { version: 1, agent: "build", model, parts: [{ type: "text", text: `Prompt ${index}` }] }, + }, + { + id: `msg_${index * 2 + 2}_assistant`, + type: "assistant" as const, + agent: "build", + model: { providerID: model.providerID, id: model.modelID }, + time: { created: index * 2 + 2, completed: index * 2 + 3 }, + cost: 1.25, + tokens: { input: 20_000 * (index + 1), output: 5_000 * (index + 1), reasoning: 0, cache: { read: 0, write: 0 } }, + content: [{ id: `txt_${index}`, type: "text" as const, text: `Answer ${index}` }], + }, +]) +Schema.decodeUnknownSync(Schema.Array(SessionMessage.Message))(messages, { onExcessProperty: "error" }) + +for (const mode of ["recorded", "rollback", "live"] as const) { + test(`context circle and detail agree with ${mode} usage`, async ({ page }) => { + const reverted = mode === "rollback" + let settled = mode !== "live" + const transport = await installSseTransport(page, { + server: `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + path: `/api/session/${sessionID}/event`, + }) + await mockOpenCodeServer(page, { + directory, + project: { id: "proj_context_usage", worktree: directory, time: { created: 1, updated: 1 }, sandboxes: [] }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 100_000 } } }, + }, + ], + connected: ["opencode"], + default: model, + }, + sessions: [ + { + id: sessionID, + directory, + title: "Context usage regression", + cost: 2.5, + time: { created: 1, updated: 5 }, + ...(reverted ? { revert: { messageID: "msg_3_user", inclusive: true } } : {}), + }, + ], + currentPageMessages: () => ({ + items: messages + .map((message) => + !settled && message.type === "assistant" && message.id === "msg_4_assistant" + ? { + ...message, + time: { created: 4 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + : message, + ) + .toReversed(), + throughSeq: 0, + }), + }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Context usage regression") + const usage = page.getByRole("button", { name: "View context usage", exact: true }).first() + const percentage = reverted ? 25 : 50 + const expectPercentage = (value: number) => + expect + .poll(async () => + usage.locator('circle[data-slot$="-progress"]').evaluate((circle) => { + const total = Number(circle.getAttribute("stroke-dasharray")) + return Math.round(100 * (1 - Number(circle.getAttribute("stroke-dashoffset")) / total)) + }), + ) + .toBe(value) + await expectPercentage(mode === "live" ? 25 : percentage) + if (mode === "live") { + settled = true + await transport.send( + event("session.next.step.ended", { + sessionID, + timestamp: 10, + assistantMessageID: "msg_4_assistant", + finish: "stop", + cost: 1.25, + tokens: { input: 40_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + ) + await expectPercentage(50) + } + await usage.hover() + await expect(page.getByRole("tooltip")).toContainText(`${percentage}%`) + await expect(page.getByRole("tooltip")).toContainText((percentage * 1_000).toLocaleString("en-US")) + await usage.click() + await expect(page.getByText("Total Tokens", { exact: true }).locator("..")).toContainText( + (percentage * 1_000).toLocaleString("en-US"), + ) + await expect(page.getByText("Usage", { exact: true }).last().locator("..")).toContainText(`${percentage}%`) + }) +} diff --git a/packages/app/e2e/regression/session-model-selection.spec.ts b/packages/app/e2e/regression/session-model-selection.spec.ts index 34fe9e5e7e18..291760ceae9a 100644 --- a/packages/app/e2e/regression/session-model-selection.spec.ts +++ b/packages/app/e2e/regression/session-model-selection.spec.ts @@ -183,12 +183,12 @@ test("keeps a manually selected model when submitting a brand-new web session", default: { opencode: "big-pickle" }, }), agents: [ + { name: "build", mode: "primary" }, { name: "Sisyphus - ultraworker", mode: "primary", model: { providerID: "opencode", modelID: "big-pickle" }, }, - { name: "build", mode: "primary" }, ], sessions, createSession: () => { diff --git a/packages/app/e2e/regression/session-model-timeline-scroll.spec.ts b/packages/app/e2e/regression/session-model-timeline-scroll.spec.ts index 21a4af1dc395..f2da546ef72b 100644 --- a/packages/app/e2e/regression/session-model-timeline-scroll.spec.ts +++ b/packages/app/e2e/regression/session-model-timeline-scroll.spec.ts @@ -21,11 +21,13 @@ const messages = Array.from({ length: 80 }, (_, index) => { id: userID, created: 1700000000000 + index * 10_000, }), - assistantMessage([textPart(`prt_history_${index}_assistant`, `Assistant reply ${index}. ${"response ".repeat(40)}`)], { - id: `msg_history_${index}_assistant`, - parentID: userID, - created: 1700000001000 + index * 10_000, - }), + assistantMessage( + [textPart(`prt_history_${index}_assistant`, `Assistant reply ${index}. ${"response ".repeat(40)}`)], + { + id: `msg_history_${index}_assistant`, + created: 1700000001000 + index * 10_000, + }, + ), ] }).flat() @@ -211,12 +213,16 @@ test("keeps scrolled timeline when switching between variant and non-variant mod const modelDialog = () => page.getByRole("dialog", { name: /Select model/i }) await composer.locator('[data-action="prompt-model"]').click() - await modelDialog().getByRole("button", { name: /Big Pickle/ }).click() + await modelDialog() + .getByRole("button", { name: /Big Pickle/ }) + .click() await expect(composer.locator('[data-action="prompt-model"]')).toContainText("Big Pickle") await page.waitForTimeout(200) await composer.locator('[data-action="prompt-model"]').click() - await modelDialog().getByRole("button", { name: /Claude Opus 4.6/ }).click() + await modelDialog() + .getByRole("button", { name: /Claude Opus 4.6/ }) + .click() await expect(composer.locator('[data-action="prompt-model"]')).toContainText("Claude Opus 4.6") await page.waitForTimeout(200) @@ -226,7 +232,9 @@ test("keeps scrolled timeline when switching between variant and non-variant mod expect(withVariant.visible.length).toBeGreaterThan(2) await composer.locator('[data-action="prompt-model"]').click() - await modelDialog().getByRole("button", { name: /DeepSeek V4 Flash Free/ }).click() + await modelDialog() + .getByRole("button", { name: /DeepSeek V4 Flash Free/ }) + .click() await expect(composer.locator('[data-action="prompt-model"]')).toContainText("DeepSeek V4 Flash Free") await page.waitForTimeout(200) diff --git a/packages/app/e2e/regression/session-pending-reload.spec.ts b/packages/app/e2e/regression/session-pending-reload.spec.ts new file mode 100644 index 000000000000..0df822c14e7d --- /dev/null +++ b/packages/app/e2e/regression/session-pending-reload.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/PendingReload" +const sessionID = "ses_pending_reload" + +test.beforeEach(async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_pending", + worktree: directory, + name: "Pending reload", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + provider: { + all: [ + { id: "opencode", name: "OpenCode", models: { test: { id: "test", name: "Test", variants: { high: {} } } } }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + projectID: "proj_pending", + directory, + title: "Stopped steering", + time: { created: 1, updated: 1 }, + }, + ], + currentPageMessages: () => ({ + items: [], + throughSeq: 2, + pending: [{ id: "msg_pending", type: "user", text: "Keep this admitted steering input", time: { created: 1 } }], + }), + }) +}) + +test("keeps stopped steering visible across page reload without resuming inference", async ({ page }) => { + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Stopped steering") + await expect(page.getByText("Keep this admitted steering input", { exact: true })).toHaveCount(1) + await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0) + await page.reload() + await expect(page.getByText("Keep this admitted steering input", { exact: true })).toHaveCount(1) + await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0) +}) + +test("stop settles before the first provider turn without removing admitted steering", async ({ page }) => { + let active = true + let pauses = 0 + await page.route("**/api/session/active", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ data: active ? { [sessionID]: { type: "running" } } : {} }), + }), + ) + await page.route(`**/api/session/${sessionID}/interrupt`, (route) => { + active = false + return route.fulfill({ status: 204 }) + }) + page.on("request", (request) => { + if (request.url().includes("queue/drain-pause")) pauses++ + }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Stopped steering") + await expect(page.getByText("Keep this admitted steering input", { exact: true })).toHaveCount(1) + await page.getByRole("button", { name: "Stop", exact: true }).click() + await expect.poll(() => active).toBe(false) + await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0) + expect(pauses).toBe(1) + await expect(page.getByText("Keep this admitted steering input", { exact: true })).toHaveCount(1) + await page.reload() + await expect(page.getByText("Keep this admitted steering input", { exact: true })).toHaveCount(1) + await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0) +}) diff --git a/packages/app/e2e/regression/session-rename.spec.ts b/packages/app/e2e/regression/session-rename.spec.ts index 2cd97c24c1c0..34fc88723ac3 100644 --- a/packages/app/e2e/regression/session-rename.spec.ts +++ b/packages/app/e2e/regression/session-rename.spec.ts @@ -1,32 +1,15 @@ import { expect, test } from "@playwright/test" -import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { fixture, currentPageMessages } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" test.beforeEach(async ({ page }) => { const sessions = fixture.sessions.map((session) => ({ ...session })) await mockOpenCodeServer(page, { - protocol: "v1", sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, - pageMessages, - }) - await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => { - if (route.request().method() !== "PATCH") return route.fallback() - const id = new URL(route.request().url()).pathname.split("/").at(-1) - const session = sessions.find((item) => item.id === id) - const payload: unknown = route.request().postDataJSON() - if ( - !session || - !payload || - typeof payload !== "object" || - !("title" in payload) || - typeof payload.title !== "string" - ) - throw new Error("Invalid rename request") - session.title = payload.title - await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } }) + currentPageMessages, }) await page.addInitScript((directory) => { localStorage.setItem( @@ -69,8 +52,8 @@ test("cancels the session heading with Escape", async ({ page }) => { }) test("keeps the draft when saving the session heading fails", async ({ page }) => { - await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => { - if (route.request().method() !== "PATCH") return route.fallback() + await page.route(/\/api\/session\/[^/]+\/rename(?:\?.*)?$/, (route) => { + if (route.request().method() !== "POST") return route.fallback() return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } }) }) await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click() diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 834aac918e85..f1c07ca7bc7d 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -107,7 +107,7 @@ test("shows a pending permission dock", async ({ page }) => { test("restores the draft caret before typing after a request dock closes", async ({ page }) => { const transport = await installSseTransport(page, { server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - path: "/global/event", + path: "/api/event", retry: 20, }) await mockServer(page, { questions: [] }) @@ -134,21 +134,21 @@ test("restores the draft caret before typing after a request dock closes", async ) .toBe(cursor) await transport.send({ - directory, - payload: { - type: "question.asked", - properties: { - id: "question-caret", - sessionID, - questions: [ - { - header: "Continue", - question: "Continue?", - options: [{ label: "Yes", description: "Continue the session" }], - }, - ], - tool: { messageID: "message-caret", callID: "call-caret" }, - }, + id: "evt_question_caret", + created: Date.now(), + location: { directory }, + type: "question.v2.asked", + data: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "msg_caret", callID: "call-caret" }, }, }) const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') @@ -156,8 +156,11 @@ test("restores the draft caret before typing after a request dock closes", async await expect(editor).toHaveCount(0) await transport.send({ - directory, - payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + id: "evt_question_caret_rejected", + created: Date.now(), + location: { directory }, + type: "question.v2.rejected", + data: { sessionID, requestID: "question-caret" }, }) await expect(question).toHaveCount(0) await expect(editor).toBeVisible() diff --git a/packages/app/e2e/regression/session-rollback-queue.spec.ts b/packages/app/e2e/regression/session-rollback-queue.spec.ts index bda850ddb647..80880719e8cc 100644 --- a/packages/app/e2e/regression/session-rollback-queue.spec.ts +++ b/packages/app/e2e/regression/session-rollback-queue.spec.ts @@ -62,7 +62,13 @@ const messages = [ ] test("preserves visible queued follow-ups when rolling back a message", async ({ page }) => { + const prompts: unknown[] = [] + const stages: unknown[] = [] + page.on("request", (request) => { + if (request.url().endsWith("/revert/stage")) stages.push(request.postDataJSON()) + }) await mockOpenCodeServer(page, { + onPrompt: ({ body }) => prompts.push(body), directory, project: { id: projectID, @@ -83,7 +89,7 @@ test("preserves visible queued follow-ups when rolling back a message", async ({ connected: ["opencode"], default: model, }, - sessions: [session], + sessions: [{ ...session }], status: { [sessionID]: { type: "busy" } }, queue: { [sessionID]: [{ id: "msg_queue_web_rollback", text: queuedText }] }, currentPageMessages: () => ({ items: messages.toReversed(), throughSeq: 0 }), @@ -97,5 +103,98 @@ test("preserves visible queued follow-ups when rolling back a message", async ({ await page.getByRole("button", { name: "Revert message", exact: true }).first().click({ force: true }) await expectAppVisible(page.locator('[data-component="session-revert-dock"]')) + await expect(page.locator('[data-component="session-revert-dock"]')).toContainText("first user prompt") + const input = page.locator('[data-component="prompt-input"]') + await expect(input).toContainText("first user prompt") + await input.fill("edited first user prompt") + await input.press("Enter") + await expect.poll(() => prompts.length).toBe(1) + expect(stages).toEqual([ + { messageID: "msg_user_0001", inclusive: true }, + { messageID: "msg_user_0001", inclusive: true }, + ]) + expect(prompts[0]).toMatchObject({ + payload: { parts: [expect.objectContaining({ type: "text", text: "edited first user prompt" })] }, + }) await expect(page.locator('[data-component="session-followup-dock"]')).toContainText(queuedText) }) + +test("does not stage rollback when inference interruption fails", async ({ page }) => { + let staged = 0 + await mockOpenCodeServer(page, { + directory, + project: { id: projectID, worktree: directory, time: { created: 1, updated: 1 }, sandboxes: [] }, + provider: { + all: [{ id: "opencode", name: "OpenCode", models: { "test-model": { id: "test-model", name: "Test Model" } } }], + connected: ["opencode"], + default: model, + }, + sessions: [{ ...session }], + status: { [sessionID]: { type: "busy" } }, + currentPageMessages: () => ({ items: messages.toReversed(), throughSeq: 0 }), + }) + page.on("request", (request) => { + if (request.url().endsWith("/revert/stage")) staged++ + }) + await page.route(/\/interrupt(?:\?.*)?$/, (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ _tag: "UnknownError", message: "Cannot interrupt", ref: "test" }), + }), + ) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, session.title) + const input = page.locator('[data-component="prompt-input"]') + await input.fill("Keep my existing draft") + await page.getByRole("button", { name: "Revert message", exact: true }).first().click({ force: true }) + await expect(page.getByText("Request failed", { exact: true })).toBeVisible() + expect(staged).toBe(0) + await expect(input).toContainText("Keep my existing draft") + await expect(page.locator('[data-component="session-revert-dock"]')).toHaveCount(0) +}) + +test("command undo and redo use the current pending input and replacement draft", async ({ page }) => { + const stages: unknown[] = [] + let cleared = 0 + await mockOpenCodeServer(page, { + directory, + findFiles: () => [], + fileList: () => [], + project: { id: projectID, worktree: directory, time: { created: 1, updated: 1 }, sandboxes: [] }, + provider: { + all: [{ id: "opencode", name: "OpenCode", models: { "test-model": { id: "test-model", name: "Test Model" } } }], + connected: ["opencode"], + default: model, + }, + sessions: [{ ...session }], + currentPageMessages: () => ({ + items: messages.slice(0, 2).toReversed(), + throughSeq: 5, + pending: [messages[2]!], + }), + }) + page.on("request", (request) => { + if (request.url().endsWith("/revert/stage")) stages.push(request.postDataJSON()) + if (request.url().endsWith("/revert/clear")) cleared++ + }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, session.title) + await expect(page.getByText("second user prompt", { exact: true })).toHaveCount(1) + const select = async (command: string) => { + await page.keyboard.press("Control+p") + const dialog = page.getByRole("dialog") + await dialog.getByRole("textbox").fill(command) + await dialog.getByText(command, { exact: true }).click() + } + const input = page.locator('[data-component="prompt-input"]') + await select("Undo") + await expect(input).toContainText("second user prompt") + await expect(page.locator('[data-component="session-revert-dock"]')).toContainText("second user prompt") + expect(stages).toEqual([{ messageID: "msg_user_0002", inclusive: true }]) + await select("Redo") + await expect.poll(() => cleared).toBe(1) + await expect(input).toHaveText("") + await expect(page.locator('[data-component="session-revert-dock"]')).toHaveCount(0) + await expect(page.getByText("second user prompt", { exact: true })).toHaveCount(1) +}) diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index c2a6ee467a94..600ee1bb1749 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -10,7 +10,7 @@ const sessionID = "ses_timeline_state_regression" const userMessageID = "msg_user_regression" const assistantMessageID = "msg_assistant_regression" const editPartID = "prt_0001_edit" -const textPartID = "prt_9999_text" +const textPartID = `${assistantMessageID}:text:0` const title = "Timeline collapse state regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } @@ -160,7 +160,7 @@ test.describe("regression: session timeline local row state", () => { expect(siblingProbe).toEqual({ fileMarker: "before", frameMarker: "before", - rowKey: `assistant-part:${userMessageID}:current:${assistantMessageID}:${editPartID}`, + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, rowMarker: "before", shadowRoots: 0, toolMarker: "before", @@ -187,7 +187,7 @@ test.describe("regression: session timeline local row state", () => { expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", frameMarker: "before", - rowKey: `assistant-part:${userMessageID}:current:${assistantMessageID}:${editPartID}`, + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, rowMarker: "before", shadowRoots: 0, toolMarker: "before", diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a01d88b0d955..c8d5d5c17b37 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -25,7 +25,7 @@ const sessionID = "ses_context_resize_regression" const title = "Context resize regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] -const followingTextID = "prt_0104_text" +const followingTextID = "msg_assistant_0010:text:0" const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)] @@ -232,19 +232,9 @@ function turn(index: number, target: boolean, status: "running" | "completed" = assistantMessage( target ? [ - contextTool( - contextIDs[0]!, - "read", - { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, - status, - ), + contextTool(contextIDs[0]!, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, status), contextTool(contextIDs[1]!, "glob", { path: directory, pattern: "**/*.ts" }, status), - contextTool( - contextIDs[2]!, - "grep", - { path: directory, pattern: "Explored", include: "*.ts" }, - status, - ), + contextTool(contextIDs[2]!, "grep", { path: directory, pattern: "Explored", include: "*.ts" }, status), contextTool(contextIDs[3]!, "list", { path: "src" }, status), textPart(followingTextID, "This assistant text is immediately after the explored context group."), ] @@ -265,11 +255,7 @@ function contextTool( return toolPart(partID, tool, status, input, { title, output: `Completed ${tool}.\n${"detail line\n".repeat(8)}` }) } -async function mockServer( - page: Page, - events: TimelineEvent[] = [], - fixtureMessages = messages, -) { +async function mockServer(page: Page, events: TimelineEvent[] = [], fixtureMessages = messages) { await mockOpenCodeServer(page, { directory, project: project(), diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 48f71ecc4cc0..72b0812a8ffd 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -12,6 +12,7 @@ import { title, userID, userMessage, + type TimelineEvent, } from "../performance/timeline-stability/fixture" import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" @@ -33,8 +34,8 @@ const olderAssistant = assistantMessage([textPart(`prt_history_root_99`, "Earlie }) const messages = [olderUser, olderAssistant, userMessage(), ...latestAssistants] const lastAssistant = latestAssistants.at(-1)! -const lastPartID = lastAssistant.content[0]!.id -const olderPartID = olderAssistant.content[0]!.id +const lastPartID = `${lastAssistant.id}:text:0` +const olderPartID = `${olderAssistant.id}:text:0` const completed = { ...lastAssistant, time: { ...lastAssistant.time, completed: lastAssistant.time.created + 15_000 }, @@ -57,7 +58,7 @@ for (const scenario of scenarios) { const pages: { cursor?: string; limit: number }[] = [] const sequence: string[] = [] const history = Promise.withResolvers() - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + const transport = await installSseTransport(page, { server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, path: `/api/session/${sessionID}/event`, retry: 20, @@ -86,7 +87,7 @@ for (const scenario of scenarios) { sessions: [session()], status: active, currentPageMessages: (_, limit, cursor) => { - pages.push({ cursor, limit }) + if (limit === historyPageSize) pages.push({ cursor, limit }) const end = cursor ? Number(cursor) : messages.length const start = Math.max(0, end - limit) return { @@ -97,7 +98,9 @@ for (const scenario of scenarios) { }, }) await page.route(`**/api/session/${sessionID}/message?**`, async (route) => { - const cursor = new URL(route.request().url()).searchParams.get("cursor") ?? undefined + const url = new URL(route.request().url()) + if (Number(url.searchParams.get("limit")) !== historyPageSize) return route.fallback() + const cursor = url.searchParams.get("cursor") ?? undefined const label = cursor ?? "latest" requests.push({ cursor, phase: "start" }) sequence.push(`messages:start:${label}`) @@ -162,8 +165,11 @@ for (const scenario of scenarios) { const viewport = page .locator(".scroll-view__viewport") .filter({ has: page.locator("[data-timeline-virtual-content]") }) - await viewport.hover() - await page.mouse.wheel(0, -100_000) + await viewport.evaluate((element) => { + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true })) + element.scrollTop = 0 + element.dispatchEvent(new Event("scroll")) + }) await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) const root = page.locator(`[data-message-id="${userID}"]`).first() @@ -190,9 +196,7 @@ for (const scenario of scenarios) { ).__historyRootProbe!.arm() }) await waitForProbeSamples(page, 0) - await expect - .poll(async () => visibleContentHidden(page), { timeout: 5_000 }) - .toBe(false) + await expect.poll(async () => visibleContentHidden(page), { timeout: 5_000 }).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() await expect(root).toBeVisible() @@ -211,7 +215,7 @@ for (const scenario of scenarios) { for (const event of events) { const beforeEvent = await probeSamples(page) if (event === idle) active[sessionID] = { type: "idle" } - await transport.send(event) + await transport.burst(Array.isArray(event) ? event : [event]) if (event === events.at(-1)) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) await waitForProbeSamples(page, beforeEvent) const current = await timelineState(page) diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index e089538411be..a0d3e59a5a1b 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -48,13 +48,13 @@ test("shows and expands a running shell command without shimmering it", async ({ await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") }) -test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { - const reasoningID = "prt_reasoning_hidden" +test("transitions thinking and hidden reasoning through busy to idle", async ({ page, browserName }) => { + const reasoningID = "msg_1001_timeline_assistant:reasoning:0" const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], settings: { showReasoningSummaries: false }, - cpuRate: 4, + cpuRate: browserName === "chromium" ? 4 : undefined, }) await timeline.sendStatus("busy", 150) @@ -91,7 +91,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.sendStatus("retry", 180) - await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="Retry"]')).toContainText("Rate limited") + await expect(page.locator('[data-timeline-row="Retry"]')).toContainText("attempt #1") + await timeline.sendStatus("retry", 100, 2) + await expect(page.locator('[data-timeline-row="Retry"]')).toContainText("attempt #2") await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await timeline.sendStatus("busy", 180, 2) await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index dda62a10abcc..9f580f2d45e0 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -99,11 +99,10 @@ test.describe("session timeline projection", () => { }) const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], { id: "msg_2001_second_assistant", - parentID: "msg_2000_second_user", created: 1700000006000, }) const timeline = await setupTimeline(page, { - messages: [firstUser, aborted, compactionMessage(), failed, nextUser, nextAssistant], + messages: [firstUser, aborted, failed, nextUser, compactionMessage({ created: 1700000005001 }), nextAssistant], }) await timeline.sendStatus("idle", 100) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) @@ -134,7 +133,6 @@ test.describe("session timeline projection", () => { const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 }) const nextAssistant = assistantMessage([], { id: "msg_2001_diff_next_assistant", - parentID: "msg_2000_diff_next_user", created: 1700000011000, }) await setupTimeline(page, { diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index b1d767456c2c..f43ea22390fa 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -69,7 +69,7 @@ for (const profile of profiles) { await timeline.sendStatus("busy", 150) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) + await expect(page.locator('[data-component="reasoning-part"]')).toHaveCount(profile.body ? 1 : 0) if (!profile.summaries && profile.reasoning.trim()) { await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() } @@ -87,6 +87,6 @@ test("does not infer reasoning visibility from provider identity", async ({ page await timeline.sendStatus("busy", 150) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() + await expect(page.locator('[data-component="reasoning-part"]')).toHaveCount(0) + await expect(page.getByText("No reasoning payload", { exact: true })).toBeVisible() }) diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index 2af4892f32cb..8f495bde4094 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test" import { assistantMessage, + assistantID, completedAssistantInfo, messageUpdated, partUpdated, @@ -29,7 +30,7 @@ test("groups singleton and separated context operations at correct boundaries", }) test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { - const textID = "prt_event_order_text" + const textID = `${assistantID}:text:0` const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false }) const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) // Hydrate already marks the incomplete assistant as busy; only force early idle. diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts index 209ed1656429..0d67f3f36446 100644 --- a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -71,8 +71,11 @@ for (const deviceScaleFactor of [1.25, 1.5]) { test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { const patchID = "prt_patch_outline" const file = { - file: "src/outline.ts", - status: "modified", + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + before: "const outline = false\n", + after: "const outline = true\n", additions: 1, deletions: 1, patch: "@@ -1 +1 @@\n-const outline = false\n+const outline = true", @@ -81,7 +84,7 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa messages: [ userMessage(), assistantMessage([ - toolPart(patchID, "apply_patch", "completed", { files: [file.file] }, { metadata: { files: [file] } }), + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), ]), ], settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, @@ -142,7 +145,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async userMessage(undefined, { id: secondUserID, created: 1700000010000 }), assistantMessage([], { id: "msg_outline_second_assistant", - parentID: secondUserID, created: 1700000011000, }), ], diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 359804997876..6cd1b338b0e5 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -4,48 +4,52 @@ import { partUpdated, setupTimeline, textPart, + timelineEvents, userMessage, } from "../performance/timeline-stability/fixture" test("keeps one connection open while delivering multiple events", async ({ page }) => { const timeline = await setupTimeline(page) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event"))) - const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event"))) + const first = await timeline.transport.burst( + timelineEvents(partUpdated(textPart("prt_transport_first", "first event"))), + ) + const second = await timeline.transport.burst( + timelineEvents(partUpdated(textPart("prt_transport_second", "second event"))), + ) - await timeline.waitForPart("prt_transport_first") - await timeline.waitForPart("prt_transport_second") - expect(first.connectionID).toBe(second.connectionID) + await expect(page.getByText("first event", { exact: true })).toBeVisible() + await expect(page.getByText("second event", { exact: true })).toBeVisible() + expect(first[0]!.connectionID).toBe(second[0]!.connectionID) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) - expect(await timeline.transport.acknowledgements()).toHaveLength(2) + expect(await timeline.transport.acknowledgements()).toHaveLength(4) }) test("delivers a burst from one stream chunk", async ({ page }) => { const timeline = await setupTimeline(page) const acknowledgements = await timeline.transport.burst([ - partUpdated(textPart("prt_transport_burst_a", "burst a")), - partUpdated(textPart("prt_transport_burst_b", "burst b")), + ...timelineEvents(partUpdated(textPart("prt_transport_burst_a", "burst a"))), + ...timelineEvents(partUpdated(textPart("prt_transport_burst_b", "burst b"))), ]) - await timeline.waitForPart("prt_transport_burst_a") - await timeline.waitForPart("prt_transport_burst_b") - expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1]) - expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2) + await expect(page.getByText("burst a", { exact: true })).toBeVisible() + await expect(page.getByText("burst b", { exact: true })).toBeVisible() + expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1]) + expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4) }) test("parses split JSON and a split multibyte code point", async ({ page }) => { const timeline = await setupTimeline(page) - const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) + const events = timelineEvents(partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))) + await timeline.transport.send(events[0]!) + const payload = events[1]! const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) const snowman = new TextEncoder().encode("\u2603")[0]! const multibyte = encoded.indexOf(snowman) const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2]) - await timeline.waitForPart("prt_transport_split") - await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText( - "split snowman \u2603\u2603\u2603", - ) + await expect(page.getByText("split snowman \u2603\u2603\u2603", { exact: true })).toBeVisible() expect(acknowledgement.chunkCount).toBe(4) }) @@ -54,19 +58,16 @@ test("delivers server heartbeat without mutating the timeline", async ({ page }) const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], }) - await timeline.waitForPart("prt_transport_steady") + await expect(page.locator('[data-component="markdown"]').getByText("steady", { exact: true })).toBeVisible() const before = await stableTimelineRows(page) await timeline.transport.writeRaw(": heartbeat\n\n") - await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) - await timeline.waitForPart(sentinelID) + expect(await stableTimelineRows(page)).toEqual(before) + + // A real append may move the assistant footer; check heartbeat stability before it. + await timeline.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) + await expect(page.getByText("heartbeat processed", { exact: true })).toBeVisible() - await expect - .poll(async () => { - const rows = await timelineRows(page) - return rows.filter((row) => before.some((item) => item.key === row.key)) - }) - .toEqual(before) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) }) @@ -76,9 +77,9 @@ test("reconnects after a clean close", async ({ page }) => { await timeline.transport.close() const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close"))) + await timeline.send(partUpdated(textPart("prt_transport_close", "after close"))) - await timeline.waitForPart("prt_transport_close") + await expect(page.getByText("after close", { exact: true })).toBeVisible() expect(second.id).toBeGreaterThan(first.id) expect((await timeline.transport.connections())[0]?.endedBy).toBe("close") }) @@ -89,24 +90,25 @@ test("reconnects after a stream error", async ({ page }) => { await timeline.transport.error("contract failure") const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) + await timeline.send(partUpdated(textPart("prt_transport_error", "after error"))) - await timeline.waitForPart("prt_transport_error") + await expect(page.getByText("after error", { exact: true })).toBeVisible() await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) expect(second.id).toBeGreaterThan(first.id) expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) -test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { +test("resumes the durable session stream after its last received sequence", async ({ page }) => { const timeline = await setupTimeline(page, { protocol: "v2" }) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { - id: "timeline-event-7", - }) - await timeline.waitForPart("prt_transport_id") + const events = timelineEvents(partUpdated(textPart("prt_transport_id", "event with id"))) + await timeline.transport.send(events[0]!) + const first = await timeline.transport.send(events[1]!, { id: "timeline-event-7" }) + await expect(page.getByText("event with id", { exact: true })).toBeVisible() await timeline.transport.error("retry with event id") const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) + expect(new URL(connection.url).searchParams.get("after")).toBe(String(events[1]!.durable?.seq)) expect(first.eventID).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined() }) @@ -148,7 +150,7 @@ function timelineRows(page: Page) { parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) => part.getAttribute("data-timeline-part-id"), ), - text: element.textContent, + text: (element as HTMLElement).innerText.replace(/\s+/g, " ").trim(), })), ) } diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 55e71212753c..c7f4d2110df9 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/TodoDockNavigation" @@ -17,17 +18,23 @@ const activeTodos = [ ] type EventPayload = { - directory: string - payload: Record + id: string + created: number + location: { directory: string } + type: string + data: Record } -test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) +test.use({ viewport: { width: 1440, height: 900 }, contextOptions: { reducedMotion: "no-preference" } }) test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { test.setTimeout(90_000) - const events: EventPayload[] = [] + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + path: "/api/event", + }) const todos: Record = { [sourceID]: [], [otherID]: [] } - const sessionStatus: Record = {} + const sessionStatus: Record = { [sourceID]: { type: "busy" } } await mockOpenCodeServer(page, { directory, @@ -57,10 +64,30 @@ test("animates todo lifecycle without replaying it across session tabs", async ( default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], - sessionStatus: { [sourceID]: { type: "busy" } }, - pageMessages: () => ({ items: [] }), - events: () => events.splice(0, 1), - eventRetry: 16, + currentPageMessages: (id) => ({ + items: + id === sourceID + ? [ + { + id: "msg_todo_assistant", + type: "assistant", + agent: "build", + model: { providerID: "opencode", id: "claude-opus-4-6" }, + time: { created: 1700000000001 }, + content: [{ id: "txt_todo", type: "text", text: "Working on the task list" }], + }, + { + id: "msg_todo_user", + type: "user", + text: "Track these tasks", + files: [], + agents: [], + time: { created: 1700000000000 }, + }, + ] + : [], + throughSeq: 0, + }), sessionStatus: () => sessionStatus, todos: (sessionID) => todos[sessionID] ?? [], }) @@ -68,17 +95,16 @@ test("animates todo lifecycle without replaying it across session tabs", async ( await page.goto(sessionHref(sourceID)) await expectSessionTitle(page, sourceTitle) + await transport.waitForConnection() const dock = page.locator('[data-component="session-todo-dock"]') await expect(dock).toHaveCount(0) - sessionStatus[sourceID] = { type: "busy" } - events.push(statusEvent(sourceID, "busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await page.waitForTimeout(700) const opening = sampleDock(page, 1_000) todos[sourceID] = activeTodos - events.push(todoEvent(sourceID, activeTodos)) + await transport.send(todoEvent(sourceID, activeTodos)) await expect(dock).toBeVisible() await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) @@ -97,11 +123,12 @@ test("animates todo lifecycle without replaying it across session tabs", async ( const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) const closing = sampleDock(page, 1_000) todos[sourceID] = completedTodos - events.push(todoEvent(sourceID, completedTodos)) + await transport.send(todoEvent(sourceID, completedTodos)) await expect(dock).toHaveCount(0) - expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + const closeSamples = await closing + expect(closeSamples.some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) todos[sourceID] = [] - events.push(todoEvent(sourceID, [])) + await transport.send(todoEvent(sourceID, [])) await switchSession(page, otherID, otherTitle) const returningEmpty = sampleDock(page, 700) @@ -122,17 +149,13 @@ function session(id: string, title: string, created: number) { } } -function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload { - return { - directory, - payload: { type: "session.status", properties: { sessionID, status: { type } } }, - } -} - function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload { return { - directory, - payload: { type: "todo.updated", properties: { sessionID, todos: next } }, + id: `evt_todo_${Date.now()}`, + created: Date.now(), + location: { directory }, + type: "todo.updated", + data: { sessionID, todos: next }, } } diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 2512da6f2e38..8c35827c7b85 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -20,7 +20,7 @@ test("navigates to a subagent child session missing from the session list", asyn await setup(page) await openChildFromParent(page) - await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("heading", { name: taskDescription, exact: true })).toBeVisible() await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) const titlebarRight = page.locator("#opencode-titlebar-right") @@ -31,7 +31,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({ const events: EventPayload[] = [] await setup(page, () => events.splice(0, 1)) await openChildFromParent(page) - await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("heading", { name: taskDescription, exact: true })).toBeVisible() events.push({ directory, diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index b969b590d89b..85f00a76130b 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { currentSession } from "../utils/mock-server" -const server = "http://127.0.0.1:4096" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const sessionA = session("ses_tab_a", "Tab A session") const sessionB = session("ses_tab_b", "Tab B session") const sessionC = session("ses_tab_c", "Tab C session") @@ -92,7 +92,8 @@ async function mockServer(page: Page) { if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session") + return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 36569c578e05..f36562e195b5 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -1,3 +1,5 @@ +import { sessionMessagePartID } from "../../src/utils/session-message" + const words = [ "alpha", "bravo", @@ -21,7 +23,7 @@ const words = [ "vector", ] -const serverKey = "http://127.0.0.1:4096" +const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -30,7 +32,15 @@ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "ma type MessagePart = Record & { id: string; type: string; text?: string; name?: string } type Message = - | { id: string; type: "user"; text: string; payload: Record; time: { created: number } } + | { + id: string + type: "user" + text: string + files: unknown[] + agents: unknown[] + payload: Record + time: { created: number } + } | { id: string type: "assistant" @@ -225,15 +235,20 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [ function renderable(part: MessagePart) { if (part.type === "tool" && part.name === "todowrite") return false - if (part.type === "text") return !!part.text.trim() - if (part.type === "reasoning") return !!part.text.trim() + if (part.type === "text") return !!part.text?.trim() + if (part.type === "reasoning") return !!part.text?.trim() return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" } function orderedParts(message: Message) { - if (message.type === "user") return [{ id: message.id, type: "user" }] + if (message.type === "user") return [{ id: sessionMessagePartID(message.id, "text", 0), type: "user" }] // Match native timeline projection: content array order, not id-sort. - return message.content.slice() + const ordinals = { text: 0, reasoning: 0 } + return message.content.map((part) => + part.type === "text" || part.type === "reasoning" + ? { ...part, id: sessionMessagePartID(message.id, part.type, ordinals[part.type]++) } + : part, + ) } export const fixture = { @@ -278,15 +293,13 @@ export const fixture = { time: { created: 1700000001000, updated: 1700000001000 }, }, ], - sourceID, - targetID, + sourceID: sourceID as typeof sourceID, + targetID: targetID as typeof targetID, messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, expected: { sourceTitle: "Uncommitted changes inquiry", targetTitle: "Example Game: sample jump movement & sample physics analysis", - targetMessageIDs: targetMessages - .filter((message) => message.type === "user") - .map((message) => message.id), + targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id), targetPartIDs: targetMessages.flatMap((message) => orderedParts(message) .filter(renderable) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 27e51b19f936..b1410cf4c7e7 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -32,33 +32,38 @@ test.describe("smoke: session timeline", () => { test.setTimeout(240_000) test("keeps the visible message fixed while prepending history", async ({ page }) => { - const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] + const requests: { before?: string; phase: "start" | "end" }[] = [] + const history = Promise.withResolvers() await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, currentPageMessages, - messageDelay: 3_000, - onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }), + }) + await page.route(`**/api/session/${fixture.targetID}/message?**`, async (route) => { + const url = new URL(route.request().url()) + // Compatibility hydration also calls this endpoint with a smaller page size. + if (Number(url.searchParams.get("limit")) !== 100) return route.fallback() + const before = url.searchParams.get("cursor") ?? undefined + requests.push({ before, phase: "start" }) + if (before) await history.promise + await route.fallback() + requests.push({ before, phase: "end" }) }) await configureSmokePage(page, fixture.directory) await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) await waitForTimelineStable(page) const scroller = timelineScroller(page) - await pointAtTimeline(page) - const deadline = Date.now() + 120_000 - while (!requests.some((request) => request.before && request.phase === "start")) { - if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") - await page.mouse.wheel(0, -240) - await page.waitForTimeout(20) - } + await scroller.evaluate((element) => { + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true })) + element.scrollTop = 0 + element.dispatchEvent(new Event("scroll")) + }) + await expect.poll(() => requests.some((request) => request.before && request.phase === "start")).toBe(true) expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) - for (let index = 0; index < 12; index++) { - await page.mouse.wheel(0, -120) - await page.waitForTimeout(20) - } + await waitForTimelineStable(page) const keys = await scroller.evaluate((element) => { const view = element.getBoundingClientRect() return [...element.querySelectorAll("[data-timeline-part-id]")] @@ -85,6 +90,7 @@ test.describe("smoke: session timeline", () => { const before = await positions() expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + history.resolve() await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true) await waitForTimelineStable(page) await expect.poll(positions).toEqual(before) @@ -125,20 +131,25 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ dirBase64, sourceID, targetID }) => { + ({ dirBase64, sourceID, targetID, server }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server: "http://127.0.0.1:4096", + server, dirBase64, sessionId, })), ), ) }, - { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + { + dirBase64: base64Encode(fixture.directory), + sourceID: fixture.sourceID, + targetID: fixture.targetID, + server: `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) @@ -155,15 +166,22 @@ test.describe("smoke: session timeline", () => { const firstPaintNodes = new WeakSet() let firstPaint = false let removedFirstPaintNodes = 0 + const removedNodes: string[] = [] let running = true new MutationObserver((records) => { if (!firstPaint || !running) return records.forEach((record) => record.removedNodes.forEach((node) => { - if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1 + if (firstPaintNodes.has(node)) { + removedFirstPaintNodes += 1 + removedNodes.push(node instanceof Element ? node.outerHTML.slice(0, 600) : node.nodeName) + } if (!(node instanceof Element)) return node.querySelectorAll("*").forEach((element) => { - if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1 + if (firstPaintNodes.has(element)) { + removedFirstPaintNodes += 1 + removedNodes.push(element.outerHTML.slice(0, 600)) + } }) }), ) @@ -187,7 +205,11 @@ test.describe("smoke: session timeline", () => { const bottom = root .querySelector('[data-timeline-row="bottom-spacer"]') ?.getBoundingClientRect() - samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) + samples.push({ + ids: visible, + last: visible.includes(last), + bottomError: bottom ? bottom.bottom - view.bottom : undefined, + }) if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { firstPaint = true root.querySelectorAll("[data-timeline-key]").forEach((row) => { @@ -203,11 +225,17 @@ test.describe("smoke: session timeline", () => { } ;( window as Window & { - __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } + __sessionTabPaint?: { + samples: typeof samples + removed: () => number + removedNodes: string[] + stop: () => void + } } ).__sessionTabPaint = { samples, removed: () => removedFirstPaintNodes, + removedNodes, stop: () => { running = false }, @@ -230,16 +258,21 @@ test.describe("smoke: session timeline", () => { __sessionTabPaint?: { samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> removed: () => number + removedNodes: string[] stop: () => void } } ).__sessionTabPaint! probe.stop() - return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } + return { + first: probe.samples.find((sample) => sample.ids.length > 0), + removed: probe.removed(), + removedNodes: probe.removedNodes, + } }) expect(first.first?.last).toBe(true) expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) - expect(first.removed).toBe(0) + expect(first.removed, JSON.stringify(first.removedNodes)).toBe(0) }) test("paints a cold session tab at the latest message", async ({ page }) => { @@ -255,20 +288,25 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ dirBase64, sourceID, targetID }) => { + ({ dirBase64, sourceID, targetID, server }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server: "http://127.0.0.1:4096", + server, dirBase64, sessionId, })), ), ) }, - { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + { + dirBase64: base64Encode(fixture.directory), + sourceID: fixture.sourceID, + targetID: fixture.targetID, + server: `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) await expectSessionTitle(page, fixture.expected.sourceTitle) @@ -314,8 +352,7 @@ test.describe("smoke: session timeline", () => { } ).__coldTabSamples return samples?.some( - (sample) => - sample.destination && sample.last && Math.abs(sample.bottomError ?? Infinity) <= 1, + (sample) => sample.destination && sample.last && Math.abs(sample.bottomError ?? Infinity) <= 1, ) }, { last: fixture.expected.targetMessageIDs.at(-1)! }, diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 4a6046e4faaf..4993860144a8 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "noEmit": true, "rootDir": "..", - "types": ["node", "bun"] + "types": ["node", "bun"], + "composite": false, + "emitDeclarationOnly": false }, "include": [ "./performance/timeline-stability/**/*.spec.ts", @@ -15,6 +17,33 @@ "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", - "./utils/**/*.ts" + "./utils/**/*.ts", + "./regression/session-pending-reload.spec.ts", + "./regression/session-context-usage.spec.ts", + "./regression/session-rollback-queue.spec.ts", + "./regression/session-model-selection.spec.ts", + "./regression/session-timeline-reasoning-projection.spec.ts", + "./regression/review-state-persistence.spec.ts", + "./regression/session-rename.spec.ts", + "./regression/project-picker-recent-search.spec.ts", + "./regression/session-model-timeline-scroll.spec.ts", + "./regression/cross-server-tab-close.spec.ts", + "./regression/remote-session-settings.spec.ts", + "./regression/remote-tab-busy.spec.ts", + "./regression/review-line-comment.spec.ts", + "./regression/tab-navigate-mousedown.spec.ts", + "./regression/session-request-docks.spec.ts", + "./regression/session-todo-dock-navigation.spec.ts", + "./regression/subagent-child-navigation.spec.ts", + "./regression/review-terminal-stacked.spec.ts", + "./regression/session-timeline-collapse-state.spec.ts", + "./regression/session-timeline-history-root.spec.ts", + "./regression/session-timeline-lifecycle-state.spec.ts", + "./regression/session-timeline-projection.spec.ts", + "./regression/session-timeline-reducer-projection.spec.ts", + "./regression/session-timeline-shell-outline.spec.ts", + "./regression/session-timeline-transport.spec.ts", + "./user-story/model-selection-flow.spec.ts", + "./smoke/session-timeline.spec.ts" ] } diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index 22b8bb41fe25..76581a5587e9 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -6,7 +6,6 @@ const directory = "C:/OpenCode/NewProject" test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { let connectedGo = false - let pendingGo = false const connections: Array<{ integrationID: string; body: unknown }> = [] await mockOpenCodeServer(page, { @@ -52,10 +51,7 @@ test("creates a session in a new project, connects OpenCode Go, and selects its integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, onConnectKey: (input) => { connections.push(input) - if (input.integrationID === "opencode-go") pendingGo = true - }, - onInstanceDispose: () => { - if (pendingGo) connectedGo = true + if (input.integrationID === "opencode-go") connectedGo = true }, sessions: [], pageMessages: () => ({ items: [] }), @@ -63,6 +59,20 @@ test("creates a session in a new project, connects OpenCode Go, and selects its path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], findFiles: () => ["NewProject"], }) + await page.route( + (url) => url.pathname === "/api/integration", + (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + location: { directory }, + data: [ + { id: "opencode", name: "OpenCode", methods: [{ type: "key" }], connections: [] }, + { id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }, + ], + }), + }), + ) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) @@ -85,7 +95,7 @@ test("creates a session in a new project, connects OpenCode Go, and selects its await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") await page.locator('[data-action="provider-connect-submit"]').click() await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) - expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }]) + expect(connections).toEqual([{ integrationID: "opencode-go", body: { key: "mock-go-api-key" } }]) await expect(modelControl).toHaveAttribute("data-control-type", "popover") await modelControl.click() diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index d4f47f172a12..910ad61dc1b3 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -25,6 +25,7 @@ export interface MockServerConfig { items: unknown[] cursor?: { previous?: string; next?: string } throughSeq: number + pending?: unknown[] } vcsDiff?: unknown[] status?: Record @@ -143,19 +144,22 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, data: [], }) - if (path === "/api/provider") return json(route, { location: location(config), data: currentCatalog(config).providers }) + if (path === "/api/provider") + return json(route, { location: location(config), data: currentCatalog(config).providers }) if (path === "/api/model") return json(route, { location: location(config), data: currentCatalog(config).models }) - if (path === "/api/model/default") return json(route, { location: location(config), data: currentCatalog(config).default }) + if (path === "/api/model/default") + return json(route, { location: location(config), data: currentCatalog(config).default }) if (path === "/api/fs/list" && config.fileList) { const files = await config.fileList(url.searchParams.get("path") ?? "") return json(route, { location: location(config), - data: files instanceof Array - ? files.map((entry) => { - const item = entry as { path: string; type: "file" | "directory" } - return { path: item.path, type: item.type } - }) - : [], + data: + files instanceof Array + ? files.map((entry) => { + const item = entry as { path: string; type: "file" | "directory" } + return { path: item.path, type: item.type } + }) + : [], }) } if (path === "/api/fs/find" && config.findFiles) { @@ -172,21 +176,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (path === "/api/agent") return json(route, { location: location(config), - data: [ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, + data: (config.agents ?? [{ name: "build", mode: "primary", hidden: false }]).map((value) => { + const agent = value as { + id?: string + name: string + mode?: string + hidden?: boolean + model?: { providerID: string; modelID: string } + variant?: string + } + return { + ...agent, + id: agent.id ?? agent.name, request: { settings: {}, headers: {}, body: {} }, permissions: [], - }, - ], + model: agent.model && { providerID: agent.model.providerID, id: agent.model.modelID }, + variant: agent.variant, + } + }), }) if (path === "/api/command") return json(route, { location: location(config), data: [] }) if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) if (path === "/api/mcp/resource") return json(route, { location: location(config), data: { resources: [], templates: [] } }) + if (path === "/api/integration") return json(route, { location: location(config), data: [] }) const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] if (integration && route.request().method() === "GET") return json(route, { @@ -230,6 +243,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) + if (path === "/vcs/diff") return json(route, config.vcsDiff ?? []) if (emptyList.has(path)) return json(route, []) if (path === "/api/session") { const directory = url.searchParams.get("directory") @@ -257,7 +271,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } if (path === "/api/session/active") { - const statuses = (config.sessionStatus ?? config.status ?? {}) as Record + const statuses = ( + typeof config.sessionStatus === "function" + ? config.sessionStatus() + : (config.sessionStatus ?? config.status ?? {}) + ) as Record return json(route, { data: Object.fromEntries( Object.entries(statuses).flatMap(([id, status]) => @@ -269,7 +287,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const queueMatch = path.match(/^\/api\/session\/([^/]+)\/queue$/) if (queueMatch && route.request().method() === "GET") return json(route, { - data: (config.queue?.[queueMatch[1]] ?? []).map((item, position) => queuedInput(queueMatch[1]!, item, position)), + data: (config.queue?.[queueMatch[1]] ?? []).map((item, position) => + queuedInput(queueMatch[1]!, item, position), + ), }) if (queueMatch && route.request().method() === "POST") return json(route, { data: { id: "msg_queue_mock" } }) const queueSendMatch = path.match(/^\/api\/session\/([^/]+)\/queue\/([^/]+)\/send$/) @@ -317,6 +337,20 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { return json(route, true) } + const renameMatch = path.match(/^\/api\/session\/([^/]+)\/rename$/) + if (renameMatch && route.request().method() === "POST") { + const session = config.sessions.find((item) => item.id === renameMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + session.title = route.request().postDataJSON().title + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + const clearRevertMatch = path.match(/^\/api\/session\/([^/]+)\/revert\/clear$/) + if (clearRevertMatch && route.request().method() === "POST") { + const session = config.sessions.find((item) => item.id === clearRevertMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + delete session.revert + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } if ( /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && route.request().method() === "POST" @@ -328,6 +362,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { } if (path in staticRoutes) return json(route, staticRoutes[path]) + const revertMatch = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/) + if (revertMatch && route.request().method() === "POST") { + const session = config.sessions.find((item) => item.id === revertMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + session.revert = { messageID: route.request().postDataJSON().messageID } + return json(route, { data: session.revert }) + } + const contextMatch = path.match(/^\/api\/session\/([^/]+)\/context$/) + if (contextMatch) { + const messages = config.currentPageMessages?.(contextMatch[1]!, Number.MAX_SAFE_INTEGER) + return json(route, { data: messages?.items.toReversed() ?? [] }) + } const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) if (currentSessionMatch) { const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) @@ -379,7 +425,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { token, ) ?? { items: [], throughSeq: 0 } config.onMessages?.({ sessionID: currentMessagesMatch[1]!, before: token, phase: "end" }) - return json(route, { data: pageData.items, throughSeq: pageData.throughSeq, cursor: pageData.cursor ?? {} }) + return json(route, { + data: pageData.items, + pending: pageData.pending, + throughSeq: pageData.throughSeq, + cursor: pageData.cursor ?? {}, + }) } const before = token ? cursors.get(token) : undefined if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) @@ -387,7 +438,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) - const pageData = config.pageMessages?.(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) ?? { + const pageData = config.pageMessages?.( + currentMessagesMatch[1], + Number(url.searchParams.get("limit") ?? 50), + before, + ) ?? { items: [], } const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined @@ -445,14 +500,32 @@ function location(config: MockServerConfig) { } } -function currentCatalog(config: MockServerConfig) { +export function currentCatalog(config: Pick) { const value = typeof config.provider === "function" ? config.provider() : config.provider if (!value || typeof value !== "object") return { providers: [], models: [], default: null } const catalog = value as { - all?: { id?: string; name?: string; package?: string; models?: Record }[] + all?: { + id?: string + name?: string + package?: string + models?: Record< + string, + { + id?: string + name?: string + family?: string + release_date?: string + variants?: Record> + limit?: unknown + } + > + }[] default?: { providerID?: string; modelID?: string } + connected?: string[] } - const providers = catalog.all ?? [] + const providers = (catalog.all ?? []).filter( + (provider) => !catalog.connected || catalog.connected.includes(provider.id ?? ""), + ) const models = providers.flatMap((provider) => Object.entries(provider.models ?? {}).map(([id, model]) => ({ id: model.id ?? id, @@ -461,8 +534,9 @@ function currentCatalog(config: MockServerConfig) { name: model.name ?? model.id ?? id, package: provider.package ?? "", capabilities: { tools: true, input: ["text"], output: ["text"] }, - variants: [], - time: { released: 0 }, + variants: Object.entries(model.variants ?? {}).map(([id, settings]) => ({ id, settings, headers: {}, body: {} })), + family: model.family ?? model.id ?? id, + time: { released: model.release_date ? Date.parse(model.release_date) : Date.now() }, cost: [], status: "active" as const, enabled: true, @@ -476,9 +550,10 @@ function currentCatalog(config: MockServerConfig) { package: provider.package ?? "", })), models, - default: models.find( - (model) => model.providerID === catalog.default?.providerID && model.id === catalog.default.modelID, - ) ?? null, + default: + models.find( + (model) => model.providerID === catalog.default?.providerID && model.id === catalog.default.modelID, + ) ?? null, } } diff --git a/packages/app/package.json b/packages/app/package.json index 52428457292a..6fd397b02790 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.27", + "version": "1.18.29", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 812173d06e41..1346a9938669 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -112,7 +112,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }, []) }) const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) - const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const working = createMemo( + () => props.controls.session.working?.() ?? sync().data.session_working(props.controls.session.id ?? ""), + ) const attachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -223,6 +225,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): resetEditingQueueID: props.resetEditingQueueID, onQueue: props.onQueue, onAbort: props.onAbort, + onAbortComplete: props.onAbortComplete, + revertMessageID: props.revertMessageID, + onRevertSubmit: props.onRevertSubmit, + onRevertSubmitComplete: props.onRevertSubmitComplete, onSubmit: props.onSubmit, model: props.controls.model.selection, }) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index caf40c314d40..2fa0c0daf336 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -252,7 +252,9 @@ export const PromptInput: Component = (props) => { return paths }) const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) - const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const working = createMemo( + () => props.controls.session.working?.() ?? sync().data.session_working(props.controls.session.id ?? ""), + ) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -1231,6 +1233,10 @@ export const PromptInput: Component = (props) => { resetEditingQueueID: props.resetEditingQueueID, onQueue: props.onQueue, onAbort: props.onAbort, + onAbortComplete: props.onAbortComplete, + revertMessageID: props.revertMessageID, + onRevertSubmit: props.onRevertSubmit, + onRevertSubmitComplete: props.onRevertSubmitComplete, onSubmit: props.onSubmit, model: props.controls.model.selection, }) @@ -1401,7 +1407,13 @@ export const PromptInput: Component = (props) => { ) { return } - if (event.altKey && store.mode === "normal" && props.controls.session.id && props.onQueue && !props.editingQueueID?.()) { + if ( + event.altKey && + store.mode === "normal" && + props.controls.session.id && + props.onQueue && + !props.editingQueueID?.() + ) { queue(event) return } @@ -1590,7 +1602,11 @@ export const PromptInput: Component = (props) => { />
- + = (props) => { variant={queueMode() ? "secondary" : "ghost"} class="size-8" aria-pressed={queueMode()} - aria-label={queueMode() ? language.t("prompt.action.sendDirect") : language.t("prompt.action.queue")} + aria-label={ + queueMode() ? language.t("prompt.action.sendDirect") : language.t("prompt.action.queue") + } onClick={() => setQueueMode((value) => !value)} /> diff --git a/packages/app/src/components/prompt-input/contracts.ts b/packages/app/src/components/prompt-input/contracts.ts index b1d096e85698..52e1c11ca151 100644 --- a/packages/app/src/components/prompt-input/contracts.ts +++ b/packages/app/src/components/prompt-input/contracts.ts @@ -26,6 +26,7 @@ export type PromptInputControls = { } session: { id?: string + working?: () => boolean tabs: { active: () => string | undefined all: () => string[] @@ -55,6 +56,10 @@ export interface PromptInputProps { resetEditingQueueID?: () => void shouldQueue?: () => boolean onQueue?: (draft: FollowupDraft) => Promise | void - onAbort?: () => void + onAbort?: () => Promise | void + onAbortComplete?: () => Promise | void + revertMessageID?: () => string | undefined + onRevertSubmit?: (messageID: string) => Promise | void + onRevertSubmitComplete?: () => void onSubmit?: () => void } diff --git a/packages/app/src/components/prompt-input/prompt-from-session-payload.test.ts b/packages/app/src/components/prompt-input/prompt-from-session-payload.test.ts index 0661efd52726..1b266ed594d0 100644 --- a/packages/app/src/components/prompt-input/prompt-from-session-payload.test.ts +++ b/packages/app/src/components/prompt-input/prompt-from-session-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionInputPayload } from "@opencode-ai/schema/session-input-payload" -import { promptFromSessionPayload } from "./prompt-from-session-payload" +import { promptFromSessionMessage, promptFromSessionPayload } from "./prompt-from-session-payload" describe("promptFromSessionPayload", () => { test("restores native text, file, agent, and image parts without an SDK message adapter", () => { @@ -65,4 +65,26 @@ describe("promptFromSessionPayload", () => { expect(promptFromSessionPayload(payload)).toEqual([{ type: "text", content: "Visible", start: 0, end: 7 }]) }) + + test("prefers the durable user payload over stale normalized parts", () => { + const payload = { + version: 1, + agent: "reviewer", + model: { providerID: "openai", modelID: "gpt-5" }, + parts: [{ type: "text", text: "durable prompt" }], + } as unknown as SessionInputPayload.Payload + const message = { + id: "msg_1", + type: "user", + text: "durable prompt", + files: [], + agents: [], + payload, + time: { created: 1 }, + } as never + + expect(promptFromSessionMessage(message, [{ id: "stale", type: "text", text: "[attachment]" } as never])).toEqual([ + { type: "text", content: "durable prompt", start: 0, end: 14 }, + ]) + }) }) diff --git a/packages/app/src/components/prompt-input/prompt-from-session-payload.ts b/packages/app/src/components/prompt-input/prompt-from-session-payload.ts index 90be8654ec51..41dfe46a9b46 100644 --- a/packages/app/src/components/prompt-input/prompt-from-session-payload.ts +++ b/packages/app/src/components/prompt-input/prompt-from-session-payload.ts @@ -1,11 +1,9 @@ import type { SessionInputPayload } from "@opencode-ai/schema/session-input-payload" -import type { - AgentPart, - FileAttachmentPart, - ImageAttachmentPart, - Prompt, -} from "@/context/prompt" +import type { SessionMessage } from "@opencode-ai/schema/session-message" +import type { Part } from "@opencode-ai/sdk/v2" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import { createLegacyBlobReference } from "@/utils/draft-store" +import { extractPromptFromParts } from "@/utils/prompt" type Inline = | { @@ -25,16 +23,10 @@ type Inline = } type Payload = SessionInputPayload.Payload | SessionInputPayload.Encoded -export function promptFromSessionPayload( - payload: Payload, - options?: { directory?: string; attachmentName?: string }, -) { - const text = - payload.parts - .flatMap((part) => - part.type === "text" && part.synthetic !== true && part.ignored !== true ? [part.text] : [], - ) - .reduce((longest, part) => (part.length > longest.length ? part : longest), "") +export function promptFromSessionPayload(payload: Payload, options?: { directory?: string; attachmentName?: string }) { + const text = payload.parts + .flatMap((part) => (part.type === "text" && part.synthetic !== true && part.ignored !== true ? [part.text] : [])) + .reduce((longest, part) => (part.length > longest.length ? part : longest), "") const relative = (value: string) => { const directory = options?.directory if (!directory) return value @@ -135,3 +127,12 @@ export function promptFromSessionPayload( if (result.length === 0) result.push({ type: "text", content: "", start: 0, end: 0 }) return images.length === 0 ? result : [...result, ...images] } + +export function promptFromSessionMessage( + message: SessionMessage.Message | undefined, + parts: Part[], + options?: { directory?: string; attachmentName?: string }, +) { + if (message?.type === "user" && message.payload) return promptFromSessionPayload(message.payload, options) + return extractPromptFromParts(parts, options) +} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 1250f1a1d41e..394207415589 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -39,6 +39,8 @@ const commandCalls: unknown[] = [] const currentCommands: Array<{ name: string; template: string; description?: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const resumedQueues: string[] = [] +const abortOrder: string[] = [] +const todoCleared: string[] = [] let params: { id?: string } = {} let search: { draftId?: string } = {} @@ -241,7 +243,9 @@ beforeAll(async () => { promptAsyncCalls.push(input) return { id: "input-1" } }, - interrupt: async () => undefined, + interrupt: async () => { + abortOrder.push("interrupt") + }, shell: async (input: { sessionID: string }) => { sentShell.push(sessionLocations[input.sessionID] ?? input.sessionID) }, @@ -277,7 +281,9 @@ beforeAll(async () => { remove: () => undefined, }, }, - set: () => undefined, + set: (...args: unknown[]) => { + if (args[0] === "todo" && typeof args[1] === "string") todoCleared.push(args[1]) + }, }), })) @@ -343,6 +349,8 @@ beforeEach(() => { commandCalls.length = 0 currentCommands.length = 0 resumedQueues.length = 0 + abortOrder.length = 0 + todoCleared.length = 0 selected = "/repo/worktree-a" variant = undefined permissionServer = "server-a" @@ -641,6 +649,85 @@ describe("prompt submit worktree selection", () => { }) describe("prompt submit queue mode", () => { + test("replaces a staged rollback instead of queueing or repeating it", async () => { + params = { id: "session-1" } + const order: string[] = [] + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + queueMode: () => true, + revertMessageID: () => "message-1", + onRevertSubmit: async (messageID) => { + order.push(`stage:${messageID}`) + }, + onRevertSubmitComplete: () => order.push("complete"), + onSubmit: () => order.push("submit"), + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + await Promise.resolve() + + expect(order).toEqual(["stage:message-1", "submit"]) + expect(promptAsyncCalls).toHaveLength(1) + expect(resumedQueues).toEqual(["session-1"]) + expect(queuedDrafts).toHaveLength(0) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(order).toEqual(["stage:message-1", "submit", "complete"]) + }) + + test("pauses queue draining before interrupting and preserves admitted input", async () => { + params = { id: "session-1" } + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => true, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onAbortComplete: () => { + abortOrder.push("complete") + }, + onAbort: async () => { + await gate + abortOrder.push("pause") + }, + }) + + const pending = submit.abort() + await Promise.resolve() + expect(abortOrder).toEqual([]) + release() + await pending + + expect(abortOrder).toEqual(["pause", "interrupt", "complete"]) + expect(todoCleared).toEqual([]) + expect(promptAsyncCalls).toHaveLength(0) + }) + test("recognizes queued slash commands from the current catalog before routing the draft", async () => { params = { id: "session-1" } promptValue = [{ type: "text", content: "/review now", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 4aeaf40de0ef..ec59c1d7d80e 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -91,7 +91,11 @@ type PromptSubmitInput = { editingQueuePayload?: Accessor resetEditingQueueID?: () => void onQueue?: (draft: FollowupDraft) => Promise | void - onAbort?: () => void + onAbort?: () => Promise | void + onAbortComplete?: () => Promise | void + revertMessageID?: Accessor + onRevertSubmit?: (messageID: string) => Promise | void + onRevertSubmitComplete?: () => void onSubmit?: () => void model?: ModelSelection } @@ -125,9 +129,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sessionID = params.id if (!sessionID) return Promise.resolve() - serverSync().session.set("todo", sessionID, []) - - input.onAbort?.() + await Promise.resolve(input.onAbort?.()).catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) const key = pendingKey(sessionID) const queued = pending.get(key) @@ -137,7 +144,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { pending.delete(key) return Promise.resolve() } - return serverSDK().currentClient.sessions.interrupt({ sessionID }).catch(() => {}) + return serverSDK() + .currentClient.sessions.interrupt({ sessionID }) + .then(() => input.onAbortComplete?.()) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) } const restoreCommentItems = ( @@ -259,8 +274,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() ?? (params.id ? { id: params.id } : undefined) if (!session && isNewSession) { - const created = await serverSDK().currentClient.sessions - .create({ + const created = await serverSDK() + .currentClient.sessions.create({ agent, model: { id: model.modelID, providerID: model.providerID, variant }, location: { directory: sessionDirectory }, @@ -302,8 +317,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { const commandInput = mode === "normal" ? text.match(/^\/(\S+)(?:[ \t]+([\s\S]*))?$/) : undefined const commandName = commandInput?.[1] const commands = commandName - ? await serverSDK().currentClient.commands - .list({ location: { directory: sessionDirectory } }) + ? await serverSDK() + .currentClient.commands.list({ location: { directory: sessionDirectory } }) .then((result) => result.data) .catch((err) => { showToast({ @@ -325,9 +340,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { variant, queueID: input.editingQueueID?.(), queuePayload: input.editingQueuePayload?.(), - ...(customCommand && commandName - ? { command: { name: commandName, arguments: commandInput?.[2] ?? "" } } - : {}), + ...(customCommand && commandName ? { command: { name: commandName, arguments: commandInput?.[2] ?? "" } } : {}), } const clearInput = () => { @@ -353,7 +366,26 @@ export function createPromptSubmit(input: PromptSubmitInput) { return true } - if (!isNewSession && mode === "normal" && (draft.queueID || input.shouldQueue?.() || queueMode)) { + const revertMessageID = input.revertMessageID?.() + if (!isNewSession && mode === "normal" && revertMessageID) { + const reverted = await Promise.resolve(input.onRevertSubmit?.(revertMessageID)) + .then(() => true) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + return false + }) + if (!reverted) return + } + + if ( + !revertMessageID && + !isNewSession && + mode === "normal" && + (draft.queueID || input.shouldQueue?.() || queueMode) + ) { const saved = await Promise.resolve(input.onQueue?.(draft)) .then(() => true) .catch((err) => { @@ -373,14 +405,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.resetQueueMode?.() - void serverSDK().currentClient.sessions.queueDrainResume({ sessionID: session.id }).catch(() => {}) + void serverSDK() + .currentClient.sessions.queueDrainResume({ sessionID: session.id }) + .catch(() => {}) input.onSubmit?.() if (mode === "shell") { clearInput() - serverSDK().currentClient.sessions - .shell({ sessionID: session.id, command: text }) + serverSDK() + .currentClient.sessions.shell({ sessionID: session.id, command: text }) .catch((err) => { showToast({ title: language.t("prompt.toast.shellSendFailed.title"), @@ -396,8 +430,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { const payload = draft.prompt.some((part) => part.type === "image") ? await createSessionPayloadWithImages(draft) : createSessionPayload(draft) - serverSDK().currentClient.sessions - .command({ + serverSDK() + .currentClient.sessions.command({ id: Identifier.ascending("message"), sessionID: session.id, name: draft.command.name, @@ -492,18 +526,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { draft, messageID, before: waitForWorktree, - }).catch((err) => { - pending.delete(pendingKey(session.id)) - if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "idle" }) - } - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - removeOptimisticMessage() - if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) + .then((sent) => { + if (sent) input.onRevertSubmitComplete?.() + }) + .catch((err) => { + pending.delete(pendingKey(session.id)) + if (sessionDirectory === projectDirectory) { + sync().set("session_status", session.id, { type: "idle" }) + } + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) + }) } return { diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 5ff332c86dbd..4f970032b862 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -12,6 +12,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { getSessionContext } from "@/components/session/session-context-metrics" +import { selectSessionContextMessages } from "@/components/session/session-context-system" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { useSettings } from "@/context/settings" @@ -22,8 +23,8 @@ interface SessionContextUsageProps { variant?: "button" | "indicator" buttonAppearance?: "default" | "v2" placement?: ComponentProps["placement"] - messages?: () => readonly SessionMessage.Message[] - session?: () => Session.Info | undefined + messages: () => readonly SessionMessage.Message[] + session: () => Session.Info | undefined } function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { @@ -72,9 +73,13 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) - const context = createMemo(() => getSessionContext(props.messages?.() ?? [], [...providers.all().values()])) + const context = createMemo(() => + getSessionContext(selectSessionContextMessages(props.messages(), props.session()?.revert), [ + ...providers.all().values(), + ]), + ) const cost = createMemo(() => { - return usd().format(props.session?.()?.cost ?? 0) + return usd().format(props.session()?.cost ?? 0) }) const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") const hasOtherTabs = createMemo(() => diff --git a/packages/app/src/components/titlebar-tab-gesture.test.ts b/packages/app/src/components/titlebar-tab-gesture.test.ts index a6ab89924872..d1dffcc43480 100644 --- a/packages/app/src/components/titlebar-tab-gesture.test.ts +++ b/packages/app/src/components/titlebar-tab-gesture.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture" +import { + canOpenTabRename, + canStartTabDrag, + forwardTabRef, + isTabCloseTarget, + openTabContextMenu, +} from "./titlebar-tab-gesture" describe("titlebar tab gestures", () => { test("excludes close controls from tab gestures", () => { @@ -31,3 +37,38 @@ describe("titlebar tab gestures", () => { expect(canStartTabDrag("touch")).toBe(false) }) }) + +describe("tab keyboard context menu", () => { + for (const key of ["ContextMenu", "F10"]) { + test(`opens from ${key} without relying on browser native synthesis`, () => { + const tab = document.createElement("a") + const menus: Event[] = [] + tab.addEventListener("contextmenu", (event) => menus.push(event)) + tab.addEventListener("keydown", (event) => + openTabContextMenu(event as KeyboardEvent & { currentTarget: HTMLElement }), + ) + const event = new KeyboardEvent("keydown", { key, shiftKey: key === "F10", cancelable: true }) + tab.dispatchEvent(event) + expect(menus).toHaveLength(1) + expect(event.defaultPrevented).toBe(true) + }) + } + + test("leaves unrelated or already-handled keys alone", () => { + const tab = document.createElement("a") + const menus: Event[] = [] + tab.addEventListener("contextmenu", (event) => menus.push(event)) + tab.addEventListener("keydown", (event) => + openTabContextMenu(event as KeyboardEvent & { currentTarget: HTMLElement }), + ) + for (const init of [{ key: "F10" }, { key: "Enter" }, { key: "F10", shiftKey: true, ctrlKey: true }]) { + const event = new KeyboardEvent("keydown", { ...init, cancelable: true }) + tab.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + } + const event = new KeyboardEvent("keydown", { key: "ContextMenu", cancelable: true }) + event.preventDefault() + tab.dispatchEvent(event) + expect(menus).toHaveLength(0) + }) +}) diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts index 0aa51189363d..6b11188cf341 100644 --- a/packages/app/src/components/titlebar-tab-gesture.ts +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -15,3 +15,14 @@ export function forwardTabRef(ref: Ref | undefined, element: HTM export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { return !dragging && !editing && !pending } + +export function openTabContextMenu(event: KeyboardEvent & { currentTarget: HTMLElement }) { + if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) return + if (event.key !== "ContextMenu" && !(event.key === "F10" && event.shiftKey)) return + event.preventDefault() + const rect = event.currentTarget.getBoundingClientRect() + // Firefox does not reliably synthesize contextmenu for keyboard activation. + event.currentTarget.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: rect.left, clientY: rect.bottom }), + ) +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index b016f286a250..e162c43ed7b6 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -12,7 +12,7 @@ import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import type { Session } from "@opencode-ai/sdk/v2" -import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" +import { canOpenTabRename, forwardTabRef, openTabContextMenu } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" import "./titlebar-tab-nav.css" @@ -209,6 +209,10 @@ export function TabNavItem(props: { data-titlebar-tab-link href={props.href} draggable={false} + onKeyDown={(event) => { + if (editing() || props.dragging) return + openTabContextMenu(event) + }} onDragStart={(event) => { event.preventDefault() event.stopPropagation() diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 3f39e50fb6de..70e734c48a43 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -77,6 +77,13 @@ function directoryState() { } describe("bootstrapDirectory", () => { + test("loads supported path metadata for current session servers", async () => { + const paths = { state: "/state", config: "/config", worktree: "/repo", directory: "/repo", home: "/home/user" } + const client = { path: { get: async () => ({ data: paths }) } } as unknown as OpencodeClient + const query = new QueryClient() + expect(await query.fetchQuery(loadPathQuery(ServerScope.local, null, client, Promise.resolve("v2")))).toEqual(paths) + }) + test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { const legacyConfigReads: string[] = [] const mcpReads: string[] = [] @@ -168,6 +175,7 @@ describe("bootstrapDirectory", () => { provider, }, sdk: { + vcs: { get: async () => ({ data: { branch: "feature", default_branch: "dev" } }) }, config: { get: async () => { throw new Error("legacy directory config should not be called") @@ -189,6 +197,7 @@ describe("bootstrapDirectory", () => { await new Promise((resolve) => setTimeout(resolve, 80)) expect(store.status).toBe("complete") + expect(store.vcs).toEqual({ branch: "feature", default_branch: "dev" }) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0f3e47381649..6ec6ed5e8cfd 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -304,9 +304,10 @@ export const loadPathQuery = ( queryOptions({ queryKey: [scope, directory, "path"], queryFn: async () => { - if ((await protocol) !== "v1") - return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } - return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + // Session protocol detection does not describe support for the shared path endpoint. + const paths = retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + if ((await protocol) === "v1") return paths + return paths.catch(() => ({ state: "", config: "", worktree: "", directory: directory ?? "", home: "" })) }, }) @@ -425,7 +426,7 @@ export async function bootstrapDirectory(input: { })), () => retry(async () => { - if ((await input.protocol) !== "v1") return + // Branch metadata still comes from the compatibility endpoint on V2 servers. return input.sdk.vcs.get().then((result) => { const next = { branch: result.data?.branch, default_branch: result.data?.default_branch } input.setStore("vcs", next) diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index a80cf2e5804d..b8e80d463edb 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -161,7 +161,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( visible, setVisibility, recent: { - list: () => recentModels()!, + // A recent-model update must not suspend and detach the session timeline. + list: () => recentModels.latest, push, }, variant: { diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 2ebf5f88ef0b..e585b4121341 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -162,6 +162,33 @@ function setup(sessions: Record) { } describe("server session", () => { + test("loads current session todos through the supported endpoint and preserves live updates", async () => { + const todos = [{ content: "Persisted task", status: "in_progress", priority: "high" }] + const calls: string[] = [] + const client = { + session: { + todo: async (input: { sessionID: string }) => { + calls.push(input.sessionID) + return { data: todos } + }, + }, + } as unknown as OpencodeClient + const store = createServerSession(client, { protocol: Promise.resolve("v2") }) + store.remember(session("child")) + await store.todo("child") + expect(store.data.todo.child).toEqual(todos) + store.apply({ + type: "todo.updated", + properties: { sessionID: "child", todos: [{ ...todos[0], status: "completed" }] }, + }) + expect(store.data.todo.child?.[0]?.status).toBe("completed") + await store.todo("child") + expect(calls).toEqual(["child"]) + await store.todo("child", { force: true }) + expect(calls).toEqual(["child", "child"]) + expect(store.data.todo.child).toEqual(todos) + }) + test("projects V2 session events into current and legacy message state", () => { const ctx = setup({ child: session("child") }) ctx.store.remember(session("child")) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 16aadad10382..5cbc21924d6f 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1380,10 +1380,6 @@ export function createServerSession( async todo(sessionID: string, request?: { force?: boolean }) { touch(sessionID) if (data.todo[sessionID] !== undefined && !request?.force) return - if ((await options?.protocol) === "v2") { - setData("todo", sessionID, []) - return - } return runInflight(inflightTodo, sessionID, () => { const active = generation(sessionID) return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { diff --git a/packages/app/src/hooks/provider-catalog.test.ts b/packages/app/src/hooks/provider-catalog.test.ts index 99e3884ab883..dace26603411 100644 --- a/packages/app/src/hooks/provider-catalog.test.ts +++ b/packages/app/src/hooks/provider-catalog.test.ts @@ -1,6 +1,11 @@ import { expect, test } from "bun:test" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { resolveDefaultModel, selectProviderCatalog } from "./provider-catalog" +import { + loadProviderChoices, + mergeProviderChoices, + resolveDefaultModel, + selectProviderCatalog, +} from "./provider-catalog" const catalog = (id: string): NormalizedProviderListResponse => ({ all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]), @@ -8,6 +13,36 @@ const catalog = (id: string): NormalizedProviderListResponse => ({ default: { [id]: `${id}-model` }, }) +test("offers disconnected integrations without replacing configured provider models", () => { + const connected = catalog("opencode") + const all = mergeProviderChoices(connected.all, [ + { id: "opencode", name: "Generic" }, + { id: "opencode-go", name: "OpenCode Go" }, + ]) + expect(all.get("opencode")).toBe(connected.all.get("opencode")) + expect(all.get("opencode-go")).toMatchObject({ id: "opencode-go", name: "OpenCode Go", models: {} }) + expect(connected.all.has("opencode-go")).toBe(false) + expect(connected.connected).toEqual(["opencode"]) + expect(mergeProviderChoices(connected.all, [])).toBe(connected.all) +}) + +test("loads current integration choices and tolerates an unavailable catalogue", async () => { + const data = [{ id: "opencode-go", name: "OpenCode Go" }] + expect(await loadProviderChoices(Promise.resolve("v2"), async () => ({ data }))).toEqual(data) + expect(await loadProviderChoices(Promise.resolve("v2"), async () => ({}))).toEqual([]) + expect(await loadProviderChoices(Promise.resolve("v2"), async () => ({ data: [] }))).toEqual([]) + expect( + await loadProviderChoices(Promise.resolve("v2"), async () => { + throw new Error("offline") + }), + ).toEqual([]) + expect( + await loadProviderChoices(Promise.resolve("v1"), async () => { + throw new Error("legacy should not request integration API") + }), + ).toEqual([]) +}) + test("selects the ready catalog for an explicit directory", () => { const directory = catalog("directory") diff --git a/packages/app/src/hooks/provider-catalog.ts b/packages/app/src/hooks/provider-catalog.ts index d02b84c6334d..affc16f261cd 100644 --- a/packages/app/src/hooks/provider-catalog.ts +++ b/packages/app/src/hooks/provider-catalog.ts @@ -2,6 +2,31 @@ import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/con const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} } +type ProviderChoice = { id: string; name: string } + +export async function loadProviderChoices( + protocol: Promise<"v1" | "v2">, + list: () => Promise<{ data?: readonly ProviderChoice[] }>, +) { + if ((await protocol) === "v1") return [] + return list() + .then((result) => (Array.isArray(result.data) ? result.data : [])) + .catch(() => []) +} + +export function mergeProviderChoices( + providers: NormalizedProviderListResponse["all"], + choices: readonly ProviderChoice[], +) { + if (!choices.length) return providers + const all = new Map(providers) + for (const choice of choices) { + if (all.has(choice.id)) continue + all.set(choice.id, { ...choice, source: "custom", env: [], options: {}, models: {} }) + } + return all +} + type DirectoryCatalog = { ready: boolean providers: NormalizedProviderListResponse diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 7a7774b62e95..36c0407923d6 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -1,9 +1,10 @@ import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { Iterable, pipe } from "effect" -import { createEffect, createMemo, type Accessor } from "solid-js" -import { selectProviderCatalog } from "./provider-catalog" +import { createEffect, createMemo, createResource, type Accessor } from "solid-js" +import { loadProviderChoices, mergeProviderChoices, selectProviderCatalog } from "./provider-catalog" export const popularProviders = [ "opencode", @@ -21,6 +22,16 @@ export function useProviders(directory: Accessor) { const serverSync = useServerSync() const params = useParams() const dir = () => (directory ? directory() : decode64(params.dir)) + const serverSDK = useServerSDK() + const [choices] = createResource( + () => ({ server: serverSDK(), directory: dir() }), + async (input) => ({ + ...input, + items: await loadProviderChoices(input.server.protocol, () => + input.server.api.integration.list(input.directory ? { location: { directory: input.directory } } : undefined), + ), + }), + ) const providers = () => { const value = dir() const projectStore = value ? serverSync().child(value)[0] : undefined @@ -38,13 +49,20 @@ export function useProviders(directory: Accessor) { }) } + const all = createMemo(() => { + const value = choices.latest + return mergeProviderChoices( + providers().all, + value?.server === serverSDK() && value.directory === dir() ? value.items : [], + ) + }) return { - all: () => providers().all, + all, default: () => providers().default, defaultModel: () => providers().defaultModel, popular: () => pipe( - providers().all, + all(), Iterable.map(([, p]) => p), Iterable.filter((p) => popularProviderSet.has(p.id)), (v) => Array.from(v), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dc5bcb51a0cb..3a4c83975387 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,5 @@ import type { FilePart, Project, Session, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import { DateTime } from "effect" 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" @@ -64,7 +65,8 @@ import { useSettingsCommand } from "@/components/settings-dialog" import { setCursorPosition } from "@/components/prompt-input/editor-dom" import { promptLength } from "@/components/prompt-input/history" import { type FollowupDraft } from "@/components/prompt-input/submit" -import { promptFromSessionPayload } from "@/components/prompt-input/prompt-from-session-payload" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { promptFromSessionMessage } from "@/components/prompt-input/prompt-from-session-payload" import { createPromptInputController, createSessionComposerController, @@ -97,7 +99,6 @@ import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { queuedFollowup, saveQueuedFollowup } from "@/pages/session/session-queue" import { diffs as list } from "@/utils/diffs" -import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" @@ -528,11 +529,21 @@ export default function Page() { : sync().data.session_working(sessionID) createEffect( on( - () => [params.id, current.readiness(), current.busy()] as const, - ([id, readiness, active]) => { + () => [params.id, current.readiness(), current.busy(), current.retry()] as const, + ([id, readiness, active, retry]) => { if (!id || readiness !== "ready") return - const next = active ? ({ type: "busy" } as const) : ({ type: "idle" } as const) - if ((sync().data.session_status[id]?.type ?? "idle") === next.type) return + const next = + active && retry + ? { + type: "retry" as const, + attempt: retry.data.attempt, + message: retry.data.error.message, + next: DateTime.toEpochMillis(retry.data.timestamp), + } + : active + ? ({ type: "busy" } as const) + : ({ type: "idle" } as const) + if (next.type !== "retry" && (sync().data.session_status[id]?.type ?? "idle") === next.type) return sync().set("session_status", id, next) }, ), @@ -1172,6 +1183,11 @@ export default function Page() { navigateMessageByOffset, setActiveMessage, focusInput, + getMessageParts: timelineParts, + getUserMessages: userMessages, + getRevertMessageID: revertMessageID, + revert: (messageID) => (params.id ? revert({ sessionID: params.id, messageID }) : undefined), + restore: (messageID) => restore(messageID), review: reviewTab, fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) @@ -1699,11 +1715,14 @@ export default function Page() { ), ) - const draft = (id: string) => - extractPromptFromParts(sync().data.part[id] ?? [], { + const draft = (id: string) => { + const message = current.messages().find((item) => item.id === id) + const parts = timelineParts(id) + return promptFromSessionMessage(message, parts.length > 0 ? parts : (sync().data.part[id] ?? []), { directory: sdk().directory, attachmentName: language.t("common.attachment"), }) + } const line = (id: string) => { const text = draft(id) @@ -1726,6 +1745,7 @@ export default function Page() { const merge = (next: Session, target = sync()) => target.session.remember(next) const roll = (sessionID: string, next: NonNullable>["revert"], target = sync()) => { + current.setRevert(sessionID, next && { ...next, messageID: SessionMessage.ID.make(next.messageID) }) const session = target.session.get(sessionID) if (!session) return target.session.remember({ ...session, revert: next }) @@ -1738,10 +1758,14 @@ export default function Page() { })), ) const [editingFollowup, setEditingFollowup] = createSignal() + const [editingRevert, setEditingRevert] = createSignal() createEffect( on( () => params.id, - () => setEditingFollowup(undefined), + () => { + setEditingFollowup(undefined) + setEditingRevert(undefined) + }, { defer: true }, ), ) @@ -1852,12 +1876,7 @@ export default function Page() { setEditingFollowup(undefined) } - const halt = (sessionID: string) => - busy(sessionID) - ? sdk() - .api.session.interrupt({ sessionID }) - .catch(() => {}) - : Promise.resolve() + const halt = (sessionID: string) => (busy(sessionID) ? sdk().api.session.interrupt({ sessionID }) : Promise.resolve()) const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { @@ -1865,14 +1884,15 @@ export default function Page() { const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) + const owner = sessionOwnership.capture() await runPromptRollbackMutation({ capturePrompt: prompt.capture, optimistic: (prompt) => { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => session.revert.stage(input)), - complete: () => undefined, + request: () => halt(input.sessionID).then(() => session.revert.stage({ ...input, inclusive: true })), + complete: () => owner.run(() => setEditingRevert(input.messageID)), rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1890,6 +1910,7 @@ export default function Page() { if (index < 0) return const next = userMessages()[index + 1] const last = target.session.get(sessionID)?.revert + const owner = sessionOwnership.capture() await runPromptRollbackMutation({ capturePrompt: prompt.capture, @@ -1904,8 +1925,10 @@ export default function Page() { request: () => !next ? halt(sessionID).then(() => session.revert.clear({ sessionID })) - : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), - complete: () => undefined, + : halt(sessionID).then(() => + session.revert.stage({ sessionID, messageID: next.id, inclusive: true }).then(() => undefined), + ), + complete: () => owner.run(() => setEditingRevert(next?.id)), rollback: () => roll(sessionID, last, target), fail, }) @@ -2120,6 +2143,8 @@ export default function Page() { centered={centered()} messages={messages} sessionMessages={timelineSessionMessages} + contextMessages={contextMessages} + contextSession={current.session} getMessageParts={timelineParts} setContentRef={(el) => { content = el @@ -2230,12 +2255,22 @@ export default function Page() { editingQueueID={() => editingFollowup()?.queueID} editingQueuePayload={() => editingFollowup()?.queuePayload} resetEditingQueueID={clearFollowupEdit} + revertMessageID={() => editingRevert() ?? revertMessageID()} + onRevertSubmit={(messageID) => { + const id = params.id + if (!id) return Promise.resolve() + return sdk() + .api.session.revert.stage({ sessionID: id, messageID, inclusive: true }) + .then(() => undefined) + }} + onRevertSubmitComplete={() => setEditingRevert(undefined)} shouldQueue={queueEnabled} onQueue={queueFollowup} + onAbortComplete={current.refresh} onAbort={() => { const id = params.id - if (!id) return - void serverSDK().currentClient.sessions.queueDrainPause({ sessionID: id }) + if (!id) return Promise.resolve() + return serverSDK().currentClient.sessions.queueDrainPause({ sessionID: id }) }} /> } @@ -2264,12 +2299,22 @@ export default function Page() { editingQueueID: () => editingFollowup()?.queueID, editingQueuePayload: () => editingFollowup()?.queuePayload, resetEditingQueueID: clearFollowupEdit, + revertMessageID: () => editingRevert() ?? revertMessageID(), + onRevertSubmit: (messageID) => { + const id = params.id + if (!id) return Promise.resolve() + return sdk() + .api.session.revert.stage({ sessionID: id, messageID, inclusive: true }) + .then(() => undefined) + }, + onRevertSubmitComplete: () => setEditingRevert(undefined), shouldQueue: queueEnabled, onQueue: queueFollowup, + onAbortComplete: current.refresh, onAbort: () => { const id = params.id - if (!id) return - void serverSDK().currentClient.sessions.queueDrainPause({ sessionID: id }) + if (!id) return Promise.resolve() + return serverSDK().currentClient.sessions.queueDrainPause({ sessionID: id }) }, }) return diff --git a/packages/app/src/pages/session/current/model.test.ts b/packages/app/src/pages/session/current/model.test.ts index 585210b95d8f..0282f4fea412 100644 --- a/packages/app/src/pages/session/current/model.test.ts +++ b/packages/app/src/pages/session/current/model.test.ts @@ -27,6 +27,20 @@ const prompted = (seq: number) => }, }) as const +const admitted = (seq: number) => + ({ + id: `evt_${seq}`, + type: "session.next.prompt.admitted", + durable: { aggregateID: "ses_test", seq, version: 1 }, + data: { + timestamp: seq, + sessionID: "ses_test", + messageID: "msg_steer", + prompt: { text: "steer now" }, + delivery: "steer", + }, + }) as const + const messages = ( items: Array<{ id: string; text: string; created: number }>, next?: string, @@ -69,6 +83,106 @@ function makePort(input?: { } describe("current session model", () => { + test("reloads authoritative messages and pending inputs after a committed replacement", async () => { + const commit = Promise.withResolvers() + let committed = false + const port = makePort() + port.sessions.events = async function* (_input, options) { + await commit.promise + yield { + id: "evt_commit", + type: "session.next.revert.committed", + durable: { aggregateID: "ses_test", seq: 3, version: 1 }, + data: { sessionID: "ses_test", messageID: "msg_old", timestamp: 3 }, + } + await new Promise((resolve) => options?.signal?.addEventListener("abort", () => resolve(), { once: true })) + } + port.messages.list = async () => ({ + ...messages(committed ? [] : [{ id: "msg_old", text: "old", created: 1 }], undefined, committed ? 3 : 1), + pending: committed ? [] : [{ id: "msg_pending", type: "user", text: "pending", time: { created: 2 } }], + }) + await new Promise((resolve, reject) => + createRoot((dispose) => { + const model = createCurrentSessionModel({ sessionID: () => "ses_test", client: () => port, autoStart: false }) + model + .start() + .then(async () => { + expect(model.messages()).toHaveLength(2) + committed = true + commit.resolve() + await until(() => model.messages().length === 0) + model.dispose() + dispose() + resolve() + }) + .catch(reject) + }), + ) + }) + + test("independent clients converge after replacement without dropping later admission", async () => { + const commit = Promise.withResolvers() + let committed = false + const port: CurrentSessionPort = { + ...makePort(), + sessions: { + ...makePort().sessions, + events: async function* (_input, options) { + await commit.promise + yield { + id: "evt_commit", + type: "session.next.revert.committed", + durable: { aggregateID: "ses_test", seq: 4, version: 1 }, + data: { sessionID: "ses_test", messageID: "msg_old", timestamp: 4 }, + } + await new Promise((resolve) => + options?.signal?.addEventListener("abort", () => resolve(), { once: true }), + ) + }, + }, + messages: { + list: async () => ({ + ...messages(committed ? [] : [{ id: "msg_old", text: "old", created: 1 }], undefined, committed ? 5 : 2), + pending: [ + { + id: committed ? "msg_new" : "msg_pending", + type: "user", + text: committed ? "replacement" : "old steer", + time: { created: committed ? 5 : 2 }, + }, + ], + }), + }, + } + await new Promise((resolve, reject) => + createRoot((dispose) => { + const clients = [0, 1].map(() => + createCurrentSessionModel({ sessionID: () => "ses_test", client: () => port, autoStart: false }), + ) + Promise.all(clients.map((client) => client.start())) + .then(async () => { + expect(clients.map((client) => client.messages().map((message) => String(message.id)))).toEqual([ + ["msg_old", "msg_pending"], + ["msg_old", "msg_pending"], + ]) + committed = true + commit.resolve() + await until(() => + clients.every((client) => client.messages().length === 1 && client.messages()[0]?.id === "msg_new"), + ) + expect(clients.map((client) => client.messages()[0])).toMatchObject([ + { id: "msg_new", text: "replacement" }, + { id: "msg_new", text: "replacement" }, + ]) + clients.forEach((client) => client.dispose()) + dispose() + resolve() + }) + .catch(reject) + }), + ) + }) + test("buffers SSE during hydration and refreshes server-authoritative queue state", async () => { let queueReads = 0 const event = prompted(4) @@ -105,6 +219,54 @@ describe("current session model", () => { }) }) + test("shows admitted steering input and reports the session busy before promotion", async () => { + const port = makePort({ events: admitted(2), pages: [messages([], undefined, 1)] }) + + await new Promise((resolve, reject) => { + createRoot((dispose) => { + const model = createCurrentSessionModel({ + sessionID: () => "ses_test", + client: () => port, + autoStart: false, + }) + model + .start() + .then(() => { + expect(model.messages().map((message) => String(message.id))).toEqual(["msg_steer"]) + expect(model.messages()[0]).toMatchObject({ type: "user", text: "steer now" }) + expect(model.busy()).toBe(true) + model.dispose() + dispose() + resolve() + }) + .catch(reject) + }) + }) + }) + + test("rehydrates stopped steering from the snapshot without marking inference busy", async () => { + const page = { + ...messages([], undefined, 5), + pending: messages([{ id: "msg_steer", text: "admitted before reload", created: 2 }]).data, + } + const port = makePort({ pages: [page] }) + await new Promise((resolve, reject) => { + createRoot((dispose) => { + const model = createCurrentSessionModel({ sessionID: () => "ses_test", client: () => port, autoStart: false }) + model + .start() + .then(() => { + expect(model.messages()).toMatchObject([{ id: "msg_steer", text: "admitted before reload" }]) + expect(model.busy()).toBe(false) + model.dispose() + dispose() + resolve() + }) + .catch(reject) + }) + }) + }) + test("loads older pages in chronological order", async () => { const port = makePort({ pages: [ diff --git a/packages/app/src/pages/session/current/model.ts b/packages/app/src/pages/session/current/model.ts index 696b96b4c633..09d94fdab3e5 100644 --- a/packages/app/src/pages/session/current/model.ts +++ b/packages/app/src/pages/session/current/model.ts @@ -8,6 +8,7 @@ import { Schema } from "effect" import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" import { currentSessionInitialState, + currentSessionMessages, reduceCurrentSession, type CurrentSessionAction, type CurrentSessionState, @@ -16,6 +17,7 @@ import { const pageSize = 100 const decodeSession = Schema.decodeUnknownSync(Session.Info) const decodeMessages = Schema.decodeUnknownSync(Schema.Array(SessionMessage.Message)) +const decodePending = Schema.decodeUnknownSync(Schema.Array(SessionMessage.User)) const decodeQueue = Schema.decodeUnknownSync(Schema.Array(SessionInput.Queued)) const decodeEvent = Schema.decodeUnknownSync(SessionEvent.All) @@ -107,6 +109,7 @@ export function createCurrentSessionModel(input: { const page = await client.messages.list({ sessionID, order: "desc", limit: pageSize }, { signal }) return { messages: decodeMessages(page.data).toReversed(), + pending: "pending" in page ? decodePending(page.pending) : undefined, cursor: page.cursor.next ?? undefined, throughSeq: page.throughSeq, } @@ -122,6 +125,17 @@ export function createCurrentSessionModel(input: { const session = events.some(eventRefreshesSession) const active = events.some(eventRefreshesActive) const context = events.some(eventRefreshesContext) + if (events.some((event) => event.type === "session.next.revert.committed")) { + const page = await newest(client, sessionID, signal) + if (signal?.aborted || input.sessionID() !== sessionID) return + dispatch({ + type: "newest-merged", + messages: page.messages, + pending: page.pending, + sequence: page.throughSeq, + hasOlder: page.cursor !== undefined, + }) + } await Promise.all([ queue ? refreshQueue(client, sessionID, signal) : undefined, session ? refreshSession(client, sessionID, signal) : undefined, @@ -226,6 +240,7 @@ export function createCurrentSessionModel(input: { dispatch({ type: "hydrated", messages: page.messages, + pending: page.pending, cursor: page.cursor ?? undefined, sequence: page.throughSeq, }) @@ -235,7 +250,13 @@ export function createCurrentSessionModel(input: { const events = buffered.splice(0) const page = await newest(client, sessionID, controller.signal) if (controller.signal.aborted || generation !== activeGeneration) return - dispatch({ type: "newest-merged", messages: page.messages, hasOlder: page.cursor !== undefined }) + dispatch({ + type: "newest-merged", + messages: page.messages, + pending: page.pending, + sequence: page.throughSeq, + hasOlder: page.cursor !== undefined, + }) events.forEach((event) => dispatch({ type: "event", event })) if (events.some((event) => event.type === "session.next.step.started")) dispatch({ type: "active-updated", active: true }) @@ -289,7 +310,13 @@ export function createCurrentSessionModel(input: { refreshActive(client, sessionID, controller.signal), refreshContext(client, sessionID, controller.signal), ]) - dispatch({ type: "newest-merged", messages: page.messages, hasOlder: page.cursor !== undefined }) + dispatch({ + type: "newest-merged", + messages: page.messages, + pending: page.pending, + sequence: page.throughSeq, + hasOlder: page.cursor !== undefined, + }) } const dispose = () => { @@ -312,7 +339,7 @@ export function createCurrentSessionModel(input: { return { state, - messages: () => state().messages, + messages: () => currentSessionMessages(state()), context: () => state().context, queue: () => state().queue, session: () => state().session, diff --git a/packages/app/src/pages/session/current/reducer.test.ts b/packages/app/src/pages/session/current/reducer.test.ts index e9a0b2f3a072..9660f0cbff43 100644 --- a/packages/app/src/pages/session/current/reducer.test.ts +++ b/packages/app/src/pages/session/current/reducer.test.ts @@ -22,6 +22,29 @@ function dispatch(actions: CurrentSessionAction[]) { } describe("current session reducer", () => { + test("does not restore discarded pending input from a stale refresh", () => { + const pending = Schema.decodeUnknownSync(SessionMessage.User)({ + id: "msg_steer", + type: "user", + text: "pending", + time: { created: 1 }, + }) + const state = dispatch([ + { type: "hydrated", messages: [], pending: [pending], sequence: 1 }, + { + type: "event", + event: decodeEvent({ + id: "evt_discarded", + type: "session.next.prompt.discarded", + durable: { aggregateID: "ses_test", seq: 2, version: 1 }, + data: { sessionID: "ses_test", messageID: "msg_steer", timestamp: 2 }, + }), + }, + { type: "newest-merged", messages: [], pending: [pending], sequence: 1, hasOlder: false }, + ]) + expect(state.pending).toEqual([]) + }) + test("hydrates a chronological native projection and preserves prepared provider context", () => { const state = dispatch([ { @@ -302,4 +325,78 @@ describe("current session reducer", () => { expect(retrying.active).toBe(true) expect(running.retry).toBeUndefined() }) + + test("projects admitted steering prompts until the durable prompt is promoted", () => { + const admitted = decodeEvent({ + id: "evt_admitted", + type: "session.next.prompt.admitted", + durable: { aggregateID: "ses_test", seq: 2, version: 1 }, + data: { + timestamp: 2, + sessionID: "ses_test", + messageID: "msg_steer", + prompt: { text: "steer now" }, + delivery: "steer", + }, + }) + const prompted = decodeEvent({ + id: "evt_prompted", + type: "session.next.prompted", + durable: { aggregateID: "ses_test", seq: 3, version: 1 }, + data: { + timestamp: 3, + sessionID: "ses_test", + messageID: "msg_steer", + prompt: { text: "steer now" }, + delivery: "steer", + }, + }) + + const pending = dispatch([ + { type: "hydrated", sequence: 1, messages: [] }, + { type: "event", event: admitted }, + ]) + + expect(pending.pending.map((message) => message.text)).toEqual(["steer now"]) + expect(pending.active).toBe(true) + + const promoted = reduceCurrentSession(pending, { type: "event", event: prompted }) + expect(promoted.pending).toEqual([]) + expect(promoted.messages).toMatchObject([{ id: "msg_steer", type: "user", text: "steer now" }]) + }) + + test("keeps an admitted steering prompt across interruption until it is discarded or promoted", () => { + const admitted = decodeEvent({ + id: "evt_admitted", + type: "session.next.prompt.admitted", + durable: { aggregateID: "ses_test", seq: 2, version: 1 }, + data: { + timestamp: 2, + sessionID: "ses_test", + messageID: "msg_steer", + prompt: { text: "preserve me" }, + delivery: "steer", + }, + }) + const interrupted = decodeEvent({ + id: "evt_failed", + type: "session.next.step.failed", + durable: { aggregateID: "ses_test", seq: 3, version: 1 }, + data: { + timestamp: 3, + sessionID: "ses_test", + assistantMessageID: "msg_assistant", + error: { type: "interrupted", message: "Provider turn interrupted" }, + }, + }) + + const state = dispatch([ + { type: "hydrated", sequence: 1, messages: [] }, + { type: "event", event: admitted }, + { type: "event", event: interrupted }, + ]) + + expect(state.pending.map((message) => message.text)).toEqual(["preserve me"]) + expect(state.active).toBe(false) + }) }) diff --git a/packages/app/src/pages/session/current/reducer.ts b/packages/app/src/pages/session/current/reducer.ts index 4b0f45be7adc..e446043943f5 100644 --- a/packages/app/src/pages/session/current/reducer.ts +++ b/packages/app/src/pages/session/current/reducer.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from "@opencode-ai/schema/session-event" -import type { SessionMessage } from "@opencode-ai/schema/session-message" +import { SessionMessage } from "@opencode-ai/schema/session-message" import type { Session } from "@opencode-ai/schema/session" import type { SessionInput } from "@opencode-ai/schema/session-input" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" @@ -7,6 +7,7 @@ import { DateTime, Effect } from "effect" export type CurrentSessionState = { readonly messages: ReadonlyArray + readonly pending: ReadonlyArray readonly context: ReadonlyArray readonly queue: ReadonlyArray readonly session?: Session.Info @@ -26,6 +27,7 @@ export type CurrentSessionAction = | { readonly type: "hydrated" readonly messages: ReadonlyArray + readonly pending?: ReadonlyArray readonly sequence?: number readonly cursor?: string } @@ -38,6 +40,8 @@ export type CurrentSessionAction = | { readonly type: "newest-merged" readonly messages: ReadonlyArray + readonly pending?: ReadonlyArray + readonly sequence?: number readonly hasOlder: boolean } | { readonly type: "event"; readonly event: SessionEvent.Event } @@ -53,6 +57,7 @@ export type CurrentSessionAction = export function currentSessionInitialState(): CurrentSessionState { return { messages: [], + pending: [], context: [], queue: [], active: false, @@ -71,6 +76,7 @@ export function reduceCurrentSession(state: CurrentSessionState, action: Current return { ...state, messages: [...action.messages], + pending: reconcilePending(action.pending ?? state.pending, action.messages), readiness: "ready" as const, cursor: action.cursor, hasOlder: action.cursor !== undefined, @@ -83,6 +89,7 @@ export function reduceCurrentSession(state: CurrentSessionState, action: Current return { ...state, messages: [...action.messages.filter((message) => !loaded.has(message.id)), ...state.messages], + pending: reconcilePending(state.pending, action.messages), cursor: action.cursor, hasOlder: action.cursor !== undefined, } @@ -100,6 +107,12 @@ export function reduceCurrentSession(state: CurrentSessionState, action: Current return { ...state, messages: Array.from(messages.values()).toSorted((left, right) => order(left).localeCompare(order(right))), + pending: reconcilePending( + action.sequence !== undefined && action.sequence >= (state.lastEventSequence ?? -1) + ? (action.pending ?? state.pending) + : state.pending, + action.messages, + ), } } case "message-replaced": { @@ -112,16 +125,18 @@ export function reduceCurrentSession(state: CurrentSessionState, action: Current DateTime.toEpochMillis(left.time.created) - DateTime.toEpochMillis(right.time.created) || String(left.id).localeCompare(String(right.id)), ), + pending: reconcilePending(state.pending, [action.message]), } const messages = [...state.messages] messages[index] = action.message - return { ...state, messages } + return { ...state, messages, pending: reconcilePending(state.pending, [action.message]) } } case "event": { const sequence = action.event.durable?.seq if (sequence !== undefined && sequence <= (state.lastEventSequence ?? -1)) return state const messages = [...state.messages] Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory({ messages }), action.event)) + const pending = reducePending(state.pending, action.event, messages) const retry = action.event.type === "session.next.retried" ? action.event @@ -133,7 +148,8 @@ export function reduceCurrentSession(state: CurrentSessionState, action: Current return { ...state, messages, - active: action.event.type === "session.next.retried" ? true : state.active, + pending, + active: activeForEvent(state.active, action.event), retry, lastEventSequence: sequence ?? state.lastEventSequence, } @@ -156,6 +172,84 @@ export function reduceCurrentSession(state: CurrentSessionState, action: Current } } +function activeForEvent(active: boolean, event: SessionEvent.Event) { + if (event.type === "session.next.retried") return true + if (event.type === "session.next.prompt.admitted" && event.data.delivery === "steer") return true + if (event.type === "session.next.prompt.expedited") return true + if (event.type === "session.next.step.started") return true + if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") return false + return active +} + +export function currentSessionMessages(state: CurrentSessionState) { + if (state.pending.length === 0) return state.messages + return [...state.messages, ...state.pending].toSorted((left, right) => order(left).localeCompare(order(right))) +} + +function reconcilePending(pending: readonly SessionMessage.User[], messages: readonly SessionMessage.Message[]) { + if (pending.length === 0) return pending + const promoted = new Set(messages.map((message) => message.id)) + return pending.filter((message) => !promoted.has(message.id)) +} + +function reducePending( + pending: readonly SessionMessage.User[], + event: SessionEvent.Event, + messages: readonly SessionMessage.Message[], +) { + const reconciled = reconcilePending(pending, messages) + if (event.type === "session.next.prompt.admitted" && event.data.delivery === "steer") + return upsertPending(reconciled, pendingUser(event.data, event.metadata)) + if (event.type === "session.next.prompt.expedited") + return upsertPending(reconciled, pendingUser(event.data, event.metadata)) + if (event.type === "session.next.prompt.revised") { + const current = reconciled.find((message) => message.id === event.data.messageID) + if (!current) return reconciled + return upsertPending( + reconciled, + pendingUser( + { + messageID: event.data.messageID, + prompt: event.data.prompt, + payload: event.data.payload, + timestamp: event.data.timestamp, + }, + current.metadata, + ), + ) + } + if (event.type === "session.next.prompt.discarded") + return reconciled.filter((message) => message.id !== event.data.messageID) + if (event.type === "session.next.prompted") return reconciled.filter((message) => message.id !== event.data.messageID) + return reconciled +} + +function upsertPending(pending: readonly SessionMessage.User[], message: SessionMessage.User) { + const next = pending.filter((current) => current.id !== message.id) + return [...next, message].toSorted((left, right) => order(left).localeCompare(order(right))) +} + +function pendingUser( + data: { + messageID: SessionMessage.ID + prompt: SessionEvent.PromptAdmitted["data"]["prompt"] + payload?: SessionEvent.PromptAdmitted["data"]["payload"] + timestamp: SessionEvent.PromptAdmitted["data"]["timestamp"] + }, + metadata?: Record, +) { + return SessionMessage.User.make({ + id: data.messageID, + type: "user", + metadata, + text: data.prompt.text, + files: data.prompt.files, + agents: data.prompt.agents, + payload: data.payload, + time: { created: data.timestamp }, + }) +} + function order(message: SessionMessage.Message) { return `${String(DateTime.toEpochMillis(message.time.created)).padStart(16, "0")}:${message.id}` } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 44e34e2c9f22..153a0c7b891c 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -53,6 +53,8 @@ import type { UserMessage, } from "@opencode-ai/sdk/v2" import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import type { Session } from "@opencode-ai/schema/session" +import type { SessionMessage } from "@opencode-ai/schema/session-message" import { showToast } from "@/utils/toast" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" @@ -252,6 +254,8 @@ export function MessageTimeline(props: { centered: boolean messages?: Accessor sessionMessages?: Accessor + contextMessages: Accessor + contextSession: Accessor getMessageParts?: (messageID: string) => PartType[] setContentRef: (el: HTMLDivElement) => void userMessages: UserMessage[] @@ -326,7 +330,7 @@ export function MessageTimeline(props: { const id = sessionID() if (!id) return return parentMessages() - .flatMap((message) => getMsgParts(message.id)) + .flatMap((message) => sync().data.part[message.id] ?? emptyParts) .map((part) => taskDescription(part, id)) .findLast((value): value is string => !!value) }) @@ -1485,6 +1489,8 @@ export function MessageTimeline(props: { void setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void + getMessageParts?: (messageID: string) => Part[] + getUserMessages?: () => UserMessage[] + getRevertMessageID?: () => string | undefined + revert?: (messageID: string) => Promise | undefined + restore?: (messageID: string) => Promise | undefined review?: () => boolean fileBrowser?: () => boolean } @@ -98,9 +103,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (!id) return [] return sync().data.message[id] ?? [] } - const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] + const userMessages = () => + actions.getUserMessages?.() ?? (messages().filter((m) => m.role === "user") as UserMessage[]) + const revertMessageID = () => (actions.getRevertMessageID ? actions.getRevertMessageID() : info()?.revert?.messageID) const visibleUserMessages = () => { - const revert = info()?.revert?.messageID + const revert = revertMessageID() if (!revert) return userMessages() const boundary = userMessages().findIndex((message) => message.id === revert) return boundary < 0 ? userMessages() : userMessages().slice(0, boundary) @@ -338,22 +345,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const session = sdk().api.session const directory = sdk().directory const promptSession = prompt.capture() - const revert = info()?.revert?.messageID + const revert = revertMessageID() const messages = userMessages() const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length if (boundary < 0) return const message = messages[boundary - 1] if (!message) return - const parts = sync().data.part[message.id] + if (actions.revert) return actions.revert(message.id) + const projectedParts = actions.getMessageParts?.(message.id) + const parts = projectedParts?.length ? projectedParts : sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await session.interrupt({ sessionID }).catch(() => {}) + await session.interrupt({ sessionID }) } await runCommand({ owner, prompt: promptSession, - request: () => session.revert.stage({ sessionID, messageID: message.id }), + request: () => session.revert.stage({ sessionID, messageID: message.id, inclusive: true }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -369,10 +378,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const messages = userMessages() const promptSession = prompt.capture() - const revertMessageID = info()?.revert?.messageID - if (!revertMessageID) return + const reverted = revertMessageID() + if (!reverted) return + if (actions.restore) return actions.restore(reverted) - const boundary = messages.findIndex((message) => message.id === revertMessageID) + const boundary = messages.findIndex((message) => message.id === reverted) if (boundary < 0) return const next = messages[boundary + 1] if (!next) { @@ -389,7 +399,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => session.revert.stage({ sessionID, messageID: next.id }), + request: () => session.revert.stage({ sessionID, messageID: next.id, inclusive: true }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(messages[boundary]), }) @@ -472,7 +482,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.redo"), description: language.t("command.session.redo.description"), slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, + disabled: !params.id || !revertMessageID(), onSelect: redo, }), sessionCommand({ diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 80d67455a621..df2af0898609 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -6,6 +6,7 @@ function setup( protocol: "v1" | "v2" | Promise<"v1" | "v2">, responses?: { vcs?: { branch: string; default_branch: string } + vcsDiff?: Array<{ file: string; additions: number; deletions: number; patch: string }> mcpResource?: { location: { directory: string; project: { id: string; directory: string } }; data: unknown } currentQuestion?: "missing" }, @@ -39,6 +40,8 @@ function setup( delivery: "steer", }) } + if (request.method === "GET" && new URL(request.url).pathname === "/vcs/diff") + return Response.json(responses?.vcsDiff ?? []) if (request.method === "GET" && new URL(request.url).pathname === "/vcs") return Response.json(responses?.vcs ?? {}) if (request.method === "GET" && new URL(request.url).pathname === "/api/mcp/resource") @@ -71,6 +74,17 @@ function setup( } describe("createCompatibleApi", () => { + test("loads V2 review diffs from the supported compatibility route", async () => { + const file = { file: "a.ts", additions: 1, deletions: 1, patch: "patch" } + const { api, requests } = setup("v2", { vcsDiff: [file] }) + expect((await api.vcs.diff({ location: { directory: "/repo" }, mode: "working" })).data).toEqual([ + { ...file, status: "modified" }, + ]) + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/vcs/diff") + expect(url.searchParams.get("mode")).toBe("git") + expect(url.searchParams.get("directory")).toBe("/repo") + }) /* test("routes V1 archive through the legacy session update", async () => { const { api, requests } = setup("v1") diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 85cf7d11e953..24b177561291 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -19,7 +19,7 @@ import type { type LegacyClient = OpencodeClient type LegacyFor = (directory?: string) => LegacyClient type CompatibleSessionApi = Omit< - SessionApi, + ServerApi["session"], "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" > & { prompt: (input: SessionPromptInput & LegacyPrompt) => Promise @@ -85,7 +85,8 @@ function sessionInfo(session: Session): SessionInfo { export function createCompatibleApi(input: CompatibleInput): CompatibleApi { const v1 = createV1Api(input) - const current = createCurrentApi(input.current, input.legacy) + // Current session support does not imply that the server implements /api/vcs. + const current = { ...createCurrentApi(input.current, input.legacy), vcs: v1.vcs } return lazyApi( input.protocol.then((protocol) => (protocol === "v1" ? v1 : current)), current, diff --git a/packages/app/src/utils/server-revert.ts b/packages/app/src/utils/server-revert.ts new file mode 100644 index 000000000000..5340f80e4d68 --- /dev/null +++ b/packages/app/src/utils/server-revert.ts @@ -0,0 +1,31 @@ +import type { OpenCodeClient } from "@opencode-ai/client/promise" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +export type ReplacementApi = Omit & { + session: Omit & { + revert: Omit & { + stage: ( + input: Parameters[0] & { inclusive?: boolean }, + ) => ReturnType + } + } +} + +// The pinned promise client predates replacement mode. Use the generated SDK +// for this opt-in request until upstream exposes the field in that client. +export function withReplacementRevert(api: OpenCodeClient, sdk: OpencodeClient): ReplacementApi { + return { + ...api, + session: { + ...api.session, + revert: { + ...api.session.revert, + stage: async (input: Parameters[0] & { inclusive?: boolean }) => { + if (!input.inclusive) return api.session.revert.stage(input) + const result = await sdk.v2.session.revert.stage(input, { throwOnError: true }) + return { ...result.data.data, files: result.data.data.files?.map((file) => ({ ...file, file: file.path })) } + }, + }, + }, + } +} diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts index 4666b7d6d03c..fee97abfc100 100644 --- a/packages/app/src/utils/server.test.ts +++ b/packages/app/src/utils/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { authFromToken, authTokenFromCredentials } from "./server" +import { authFromToken, authTokenFromCredentials, createApiForServer } from "./server" describe("authFromToken", () => { test("decodes basic auth credentials from auth_token", () => { @@ -21,3 +21,27 @@ describe("authTokenFromCredentials", () => { expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) }) }) + +describe("replacement revert transport", () => { + test("sends opt-in replacement through the generated SDK with server authentication", async () => { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + return Response.json({ + data: { + messageID: "msg_original", + files: [{ path: "a.ts", status: "modified", additions: 1, deletions: 1, patch: "patch" }], + }, + }) + }, + { preconnect: () => {} }, + ) + const api = createApiForServer({ server: { url: "http://localhost:4096", password: "test-only" }, fetch: fetcher }) + const result = await api.session.revert.stage({ sessionID: "ses_test", messageID: "msg_original", inclusive: true }) + expect(requests[0]?.headers.get("authorization")).toBe(`Basic ${btoa("opencode:test-only")}`) + expect(await requests[0]?.json()).toEqual({ messageID: "msg_original", inclusive: true }) + expect(result.files?.[0]?.file).toBe("a.ts") + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index e3264cee22ab..47e574c5cd46 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { withReplacementRevert } from "./server-revert" +import { OpenCode } from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -51,23 +52,23 @@ export function createSdkForServer({ }) } -export function createApiForServer(input: { - server: ServerConnection.HttpBase - fetch?: typeof globalThis.fetch -}): OpenCodeClient { - return OpenCode.make({ - baseUrl: input.server.url, - fetch: input.fetch, - headers: input.server.password - ? { - Authorization: `Basic ${authTokenFromCredentials({ - username: input.server.username, - password: input.server.password, - })}`, - } - : undefined, - }) +export function createApiForServer(input: { server: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }) { + return withReplacementRevert( + OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }), + createSdkForServer(input), + ) } export { createCurrentClientForServer } from "./current-client" -export type ServerApi = OpenCodeClient +export type ServerApi = ReturnType diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index 2b0f93ff41ae..1e4aea53e18b 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -7,6 +7,40 @@ import { normalizeCurrentSessionMessages, normalizeSessionMessages } from "./ses const decodeCurrentMessage = Schema.decodeUnknownSync(SessionMessage.Message) describe("normalizeSessionMessages", () => { + test("maps current task child identities for navigation without replacing legacy metadata", () => { + for (const structured of [{ sessionID: "child" }, { sessionID: "child", sessionId: "legacy-child" }]) { + const result = normalizeCurrentSessionMessages("parent", [ + decodeCurrentMessage({ + id: "msg_user", + type: "user", + text: "Delegate", + files: [], + agents: [], + time: { created: 1 }, + }), + decodeCurrentMessage({ + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: 2, completed: 3 }, + content: [ + { + id: "task", + type: "tool", + name: "task", + time: { created: 2, completed: 3 }, + state: { status: "completed", input: { description: "Child task" }, structured, content: [] }, + }, + ], + }), + ]) + expect(result.parts.get("msg_assistant")).toMatchObject([ + { type: "tool", state: { metadata: { ...structured, sessionId: structured.sessionId ?? "child" } } }, + ]) + } + }) + test("adapts current messages for the compatibility timeline", () => { const result = normalizeCurrentSessionMessages("ses_1", [ decodeCurrentMessage({ @@ -62,6 +96,54 @@ describe("normalizeSessionMessages", () => { ]) }) + test("preserves current user attachments and payload comments in the compatibility projection", () => { + const text = "Use @explore with @src/a.ts" + const parts = [ + { + type: "text", + text: "Comment context", + synthetic: true, + metadata: { + opencodeComment: { path: "src/a.ts", comment: "Keep stable", selection: { startLine: 4, endLine: 8 } }, + }, + }, + { type: "text", text }, + { type: "file", mime: "image/png", filename: "pixel.png", url: "data:image/png;base64,eA==" }, + { + type: "file", + mime: "text/plain", + filename: "a.ts", + url: "src/a.ts", + source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } }, + }, + { type: "agent", name: "explore", source: { value: "@explore", start: 4, end: 12 } }, + ] + const message = { + id: "msg_rich", + type: "user", + text, + files: [{ uri: "data:image/png;base64,eA==", mime: "image/png", name: "pixel.png" }], + agents: [{ name: "explore", source: { text: "@explore", start: 4, end: 12 } }], + time: { created: 1 }, + } + const projected = normalizeCurrentSessionMessages("ses_1", [decodeCurrentMessage(message)]) + expect(projected.parts.get("msg_rich")).toMatchObject([ + { type: "text", text }, + { type: "file", url: "data:image/png;base64,eA==", filename: "pixel.png" }, + { type: "agent", source: { value: "@explore", start: 4, end: 12 } }, + ]) + const result = normalizeCurrentSessionMessages("ses_1", [ + decodeCurrentMessage({ + ...message, + payload: { version: 1, agent: "build", model: { providerID: "provider", modelID: "model" }, parts }, + }), + ]) + expect(result.parts.get("msg_rich")).toMatchObject(parts) + expect( + result.parts.get("msg_rich")?.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_rich"), + ).toBe(true) + }) + test("renders current synthetic text, structured tool metadata, and snapshot diffs", () => { const result = normalizeCurrentSessionMessages("ses_1", [ decodeCurrentMessage({ diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index f03a60bf1c1d..c3b1cd8905af 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -44,6 +44,18 @@ export function normalizeCurrentSessionMessages(sessionID: string, source: reado } function toLegacyMessage(message: CurrentEncodedMessage): SessionMessageInfo { + if (message.type === "user") { + return { + ...message, + files: message.files?.map((file) => ({ + ...file, + data: "", + source: { type: "uri" as const, uri: file.uri }, + mention: file.source, + })), + agents: message.agents?.map((agent) => ({ ...agent, mention: agent.source })), + } as SessionMessageInfo + } if (message.type === "shell") { return { ...message, @@ -111,6 +123,8 @@ function normalizeToolInput(name: string, input: Record) { } function normalizeToolMetadata(name: string, metadata: Record) { + if (name === "task" && typeof metadata.sessionID === "string" && metadata.sessionId === undefined) + return { ...metadata, sessionId: metadata.sessionID } if (name !== "edit" || !Array.isArray(metadata.files)) return metadata const file = metadata.files.find(record) if (!file || typeof file.file !== "string") return metadata @@ -284,7 +298,19 @@ function userMessage( } } -function userParts(sessionID: string, message: SessionMessageUser): Part[] { +function userParts( + sessionID: string, + message: SessionMessageUser & { payload?: Extract["payload"] }, +): Part[] { + if (message.payload) { + const ordinals = { text: 0, file: 0, agent: 0, subtask: 0 } + return message.payload.parts.map((part) => ({ + ...part, + id: `${message.id}:${part.type}:${ordinals[part.type]++}`, + sessionID, + messageID: message.id, + })) + } return [ textPart(sessionID, message.id, 0, message.text), ...(message.files ?? []).map( @@ -391,6 +417,12 @@ function textPart(sessionID: string, messageID: string, ordinal: number, text: s function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { const start = tool.time.ran ?? tool.time.created + const metadata = + "structured" in tool.state && record(tool.state.structured) + ? tool.state.structured + : "metadata" in tool.state + ? (tool.state.metadata ?? {}) + : {} const state = (() => { if (tool.state.status === "streaming") { const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) @@ -401,8 +433,7 @@ 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 ?? {}), + metadata: normalizeToolMetadata(tool.name, metadata), time: { start }, } } @@ -411,8 +442,7 @@ 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 ?? {}), + metadata: normalizeToolMetadata(tool.name, metadata), time: { start, end: tool.time.completed ?? start }, } } @@ -436,8 +466,7 @@ 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 ?? {}), + metadata: normalizeToolMetadata(tool.name, metadata), time: { start, end: tool.time.completed ?? start }, attachments: attachments.length ? attachments : undefined, } diff --git a/packages/cli/package.json b/packages/cli/package.json index 887101bf10fc..dc70e71ef439 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "bin": { diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index a397c5f81c1b..a1ef25e38acd 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -301,11 +301,12 @@ type Endpoint3_23Input = { readonly sessionID: Endpoint3_23Request["params"]["sessionID"] readonly messageID: Endpoint3_23Request["payload"]["messageID"] readonly files?: Endpoint3_23Request["payload"]["files"] + readonly inclusive?: Endpoint3_23Request["payload"]["inclusive"] } const Endpoint3_23 = (raw: RawClient["server.session"]) => (input: Endpoint3_23Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, - payload: { messageID: input["messageID"], files: input["files"] }, + payload: { messageID: input["messageID"], files: input["files"], inclusive: input["inclusive"] }, }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index cf4e04a186e6..47586f205b54 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -630,7 +630,7 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, - body: { messageID: input["messageID"], files: input["files"] }, + body: { messageID: input["messageID"], files: input["files"], inclusive: input["inclusive"] }, successStatus: 200, declaredStatuses: [404, 500, 400, 401], empty: false, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3a04f7d2c885..ca581b2cdb7f 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -279,6 +279,8 @@ export type SessionsListOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -354,6 +356,8 @@ export type SessionsCreateOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -405,6 +409,8 @@ export type SessionsGetOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -457,6 +463,8 @@ export type SessionsUpdateOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -509,6 +517,8 @@ export type SessionsForkOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -558,6 +568,8 @@ export type SessionsShareOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -607,6 +619,8 @@ export type SessionsUnshareOutput = { readonly subpath?: string readonly revert?: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -2623,13 +2637,28 @@ export type SessionsWaitOutput = void export type SessionsStageInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] - readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] + readonly messageID: { + readonly messageID: string + readonly files?: boolean | undefined + readonly inclusive?: boolean | undefined + }["messageID"] + readonly files?: { + readonly messageID: string + readonly files?: boolean | undefined + readonly inclusive?: boolean | undefined + }["files"] + readonly inclusive?: { + readonly messageID: string + readonly files?: boolean | undefined + readonly inclusive?: boolean | undefined + }["inclusive"] } export type SessionsStageOutput = { readonly data: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -3988,6 +4017,8 @@ export type SessionsHistoryOutput = { readonly sessionID: string readonly revert: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -5110,6 +5141,8 @@ export type SessionsEventsOutput = readonly sessionID: string readonly revert: { readonly messageID: string + readonly inclusive?: boolean + readonly inputThroughSeq?: number readonly partID?: string readonly snapshot?: string readonly diff?: string @@ -5653,6 +5686,99 @@ export type MessagesListOutput = { readonly time: { readonly created: number } } > + readonly pending?: ReadonlyArray<{ + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly payload?: { + readonly version: 1 + readonly agent: string + readonly model: { readonly providerID: string; readonly modelID: string; readonly variant?: string } + readonly tools?: { readonly [x: string]: boolean } + readonly system?: string + readonly format?: + | { readonly type: "text" } + | { + readonly type: "json_schema" + readonly schema: { readonly [x: string]: JsonValue } + readonly retryCount?: number | null | null + } + readonly parts: ReadonlyArray< + | { + readonly id?: string + readonly type: "text" + readonly text: string + readonly synthetic?: boolean + readonly ignored?: boolean + readonly time?: { readonly start: number; readonly end?: number } + readonly metadata?: { readonly [x: string]: JsonValue } + } + | { + readonly id?: string + readonly type: "file" + readonly mime: string + readonly filename?: string + readonly url: string + readonly source?: + | { + readonly type: "file" + readonly path: string + readonly text: { readonly value: string; readonly start: number; readonly end: number } + } + | { + readonly type: "symbol" + readonly path: string + readonly range: { + readonly start: { readonly line: number; readonly character: number } + readonly end: { readonly line: number; readonly character: number } + } + readonly name: string + readonly kind: number + readonly text: { readonly value: string; readonly start: number; readonly end: number } + } + | { + readonly type: "resource" + readonly clientName: string + readonly uri: string + readonly text: { readonly value: string; readonly start: number; readonly end: number } + } + } + | { + readonly id?: string + readonly type: "agent" + readonly name: string + readonly source?: { readonly value: string; readonly start: number; readonly end: number } + } + | { + readonly id?: string + readonly type: "subtask" + readonly prompt: string + readonly description: string + readonly agent: string + readonly model?: { readonly providerID: string; readonly modelID: string; readonly variant?: string } + readonly command?: string + } + > + readonly permissions?: ReadonlyArray<{ + readonly permission: string + readonly pattern: string + readonly action: "allow" | "deny" | "ask" + }> + } + readonly type: "user" + }> | null readonly throughSeq: number readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 1fa68a31d12d..66f983ad82c9 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.27", + "version": "1.18.29", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 64138086b621..f5d06e24d772 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index a9ca603119e6..fb7dae2a844e 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -349,7 +349,7 @@ export const dict = { "go.faq.a5.museRetention": "أسعار توكنات مخفّضة للغاية مقابل منح الإذن باستخدام مطالباتك وإكمالات النموذج لتدريب نماذج Meta المستقبلية.", "go.faq.a5.learnMore": "اعرف المزيد", - "go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أغسطس 2026.", + "go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 30 سبتمبر 2026.", "go.faq.a5.beforeExceptions": "تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع", "go.faq.a5.exceptionsLink": "الاستثناءات التالية", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 2aa425607815..7faba59026e3 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -358,7 +358,7 @@ export const dict = { "Preços de tokens com grandes descontos em troca da permissão para usar seus prompts e respostas geradas para treinar futuros modelos da Meta.", "go.faq.a5.learnMore": "Saiba mais", "go.faq.a5.deepseekRetention": - "O acordo de ZDR é renovado mensalmente. O acordo atual é válido até 31 de agosto de 2026.", + "O acordo de ZDR é renovado mensalmente. O acordo atual é válido até 30 de setembro de 2026.", "go.faq.a5.beforeExceptions": "Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as", "go.faq.a5.exceptionsLink": "seguintes exceções", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 86a672ffc23c..a62039593876 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -354,7 +354,7 @@ export const dict = { "Kraftigt nedsatte tokenpriser til gengæld for tilladelse til at bruge dine prompts og modelsvar til at træne fremtidige Meta-modeller.", "go.faq.a5.learnMore": "Læs mere", "go.faq.a5.deepseekRetention": - "ZDR-aftalen fornyes månedligt. Den nuværende aftale er gyldig til og med 31. august 2026.", + "ZDR-aftalen fornyes månedligt. Den nuværende aftale er gyldig til og med 30. september 2026.", "go.faq.a5.beforeExceptions": "Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 9a7642bc0345..5dab86ea585d 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -356,7 +356,7 @@ export const dict = { "Stark vergünstigte Tokenpreise im Gegenzug für die Erlaubnis, deine Prompts und Vervollständigungen zum Trainieren zukünftiger Meta-Modelle zu verwenden.", "go.faq.a5.learnMore": "Mehr erfahren", "go.faq.a5.deepseekRetention": - "Die ZDR-Vereinbarung wird monatlich erneuert. Die aktuelle Vereinbarung gilt bis einschließlich 31. August 2026.", + "Die ZDR-Vereinbarung wird monatlich erneuert. Die aktuelle Vereinbarung gilt bis einschließlich 30. September 2026.", "go.faq.a5.beforeExceptions": "Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den", "go.faq.a5.exceptionsLink": "folgenden Ausnahmen", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 8c2901c39d28..ee4dfe1aaf43 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -352,7 +352,7 @@ export const dict = { "go.faq.a5.museRetention": "Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models.", "go.faq.a5.deepseekRetention": - "ZDR agreement is renewed monthly. The current agreement is valid through August 31, 2026.", + "ZDR agreement is renewed monthly. The current agreement is valid through September 30, 2026.", "go.faq.a5.learnMore": "Learn more", "go.faq.a5.beforeExceptions": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 59aa3abbf313..f9c7bbbd52eb 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -359,7 +359,7 @@ export const dict = { "Precios de tokens muy reducidos a cambio de permitir que tus prompts y las respuestas generadas se utilicen para entrenar futuros modelos de Meta.", "go.faq.a5.learnMore": "Más información", "go.faq.a5.deepseekRetention": - "El acuerdo de ZDR se renueva mensualmente. El acuerdo actual es válido hasta el 31 de agosto de 2026.", + "El acuerdo de ZDR se renueva mensualmente. El acuerdo actual es válido hasta el 30 de septiembre de 2026.", "go.faq.a5.beforeExceptions": "Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las", "go.faq.a5.exceptionsLink": "siguientes excepciones", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 4a14e04521c7..ea7fccc63dc0 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -359,7 +359,7 @@ export const dict = { "Des tarifs de tokens fortement réduits en échange de l’autorisation d’utiliser vos prompts et vos complétions pour entraîner de futurs modèles Meta.", "go.faq.a5.learnMore": "En savoir plus", "go.faq.a5.deepseekRetention": - "L’accord ZDR est renouvelé chaque mois. L’accord actuel est valable jusqu’au 31 août 2026.", + "L’accord ZDR est renouvelé chaque mois. L’accord actuel est valable jusqu’au 30 septembre 2026.", "go.faq.a5.beforeExceptions": "Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 0bc34f90b25b..fe44d02e022e 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -353,7 +353,7 @@ export const dict = { "I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.", "go.faq.a5.learnMore": "Scopri di più", "go.faq.a5.deepseekRetention": - "L'accordo ZDR viene rinnovato mensilmente. L'accordo attuale è valido fino al 31 agosto 2026.", + "L'accordo ZDR viene rinnovato mensilmente. L'accordo attuale è valido fino al 30 settembre 2026.", "go.faq.a5.museRetention": "Prezzi dei token fortemente scontati in cambio dell'autorizzazione a utilizzare i tuoi prompt e completamenti per addestrare futuri modelli Meta.", "go.faq.a5.beforeExceptions": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 55715333fc42..a79bdd12ecdd 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -353,7 +353,7 @@ export const dict = { "go.faq.a5.museRetention": "将来のMetaモデルのトレーニングにプロンプトと生成結果を使用する許可と引き換えに、トークン料金が大幅に割引されます。", "go.faq.a5.learnMore": "詳しく見る", - "go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年8月31日まで有効です。", + "go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年9月30日まで有効です。", "go.faq.a5.beforeExceptions": "Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(", "go.faq.a5.exceptionsLink": "以下の例外", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 6ad784dd9b92..72470d0330b8 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -348,7 +348,7 @@ export const dict = { "go.faq.a5.museRetention": "향후 Meta 모델 학습에 사용자의 프롬프트와 생성 결과를 사용할 수 있도록 허용하는 대신 토큰 가격이 대폭 할인됩니다.", "go.faq.a5.learnMore": "자세히 알아보기", - "go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 8월 31일까지 유효합니다.", + "go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 9월 30일까지 유효합니다.", "go.faq.a5.beforeExceptions": "Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,", "go.faq.a5.exceptionsLink": "다음 예외", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 416e33bfca6c..2357766d2504 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -352,7 +352,7 @@ export const dict = { "Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.", "go.faq.a5.learnMore": "Les mer", "go.faq.a5.deepseekRetention": - "ZDR-avtalen fornyes månedlig. Den gjeldende avtalen er gyldig til og med 31. august 2026.", + "ZDR-avtalen fornyes månedlig. Den gjeldende avtalen er gyldig til og med 30. september 2026.", "go.faq.a5.museRetention": "Kraftig rabatterte tokenpriser i bytte mot tillatelse til å bruke ledetekstene og fullføringene dine til å trene fremtidige Meta-modeller.", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 88ae438aa210..e2bb3d81167d 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -353,7 +353,7 @@ export const dict = { "go.faq.a5.gptRetention": "Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.", "go.faq.a5.learnMore": "Dowiedz się więcej", - "go.faq.a5.deepseekRetention": "Umowa ZDR jest odnawiana co miesiąc. Obecna umowa obowiązuje do 31 sierpnia 2026 r.", + "go.faq.a5.deepseekRetention": "Umowa ZDR jest odnawiana co miesiąc. Obecna umowa obowiązuje do 30 września 2026 r.", "go.faq.a5.museRetention": "Znacznie obniżone ceny tokenów w zamian za zgodę na wykorzystanie Twoich promptów i odpowiedzi do trenowania przyszłych modeli Meta.", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index a4884471e2fe..9e05c954916c 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -358,7 +358,7 @@ export const dict = { "Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.", "go.faq.a5.learnMore": "Подробнее", "go.faq.a5.deepseekRetention": - "Соглашение ZDR продлевается ежемесячно. Текущее соглашение действует до 31 августа 2026 года.", + "Соглашение ZDR продлевается ежемесячно. Текущее соглашение действует до 30 сентября 2026 года.", "go.faq.a5.museRetention": "Значительно сниженная стоимость токенов в обмен на разрешение использовать ваши промпты и ответы для обучения будущих моделей Meta.", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 5821a67ccf94..56a45ba7da7d 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -352,7 +352,7 @@ export const dict = { "go.faq.a5.museRetention": "ราคาของ token ลดลงอย่างมาก โดยแลกกับการอนุญาตให้นำพรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณไปใช้ฝึกโมเดล Meta ในอนาคต", "go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม", - "go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 สิงหาคม 2026", + "go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 30 กันยายน 2026", "go.faq.a5.beforeExceptions": "โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 479c67e8cb1c..09014d0b6ae8 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -355,7 +355,7 @@ export const dict = { "Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.", "go.faq.a5.learnMore": "Daha fazla bilgi", "go.faq.a5.deepseekRetention": - "ZDR anlaşması aylık olarak yenilenir. Mevcut anlaşma 31 Ağustos 2026 tarihine kadar geçerlidir.", + "ZDR anlaşması aylık olarak yenilenir. Mevcut anlaşma 30 Eylül 2026 tarihine kadar geçerlidir.", "go.faq.a5.museRetention": "İstemlerinizi ve tamamlamalarınızı gelecekteki Meta modellerini eğitmek için kullanma izni karşılığında büyük ölçüde indirimli token fiyatları.", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 843a35a0ea52..ac613a7d974e 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -352,7 +352,7 @@ export const dict = { "go.faq.a5.gptRetention": "Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.", "go.faq.a5.learnMore": "Докладніше", - "go.faq.a5.deepseekRetention": "Угода ZDR поновлюється щомісяця. Поточна угода дійсна до 31 серпня 2026 року.", + "go.faq.a5.deepseekRetention": "Угода ZDR поновлюється щомісяця. Поточна угода дійсна до 30 вересня 2026 року.", "go.faq.a5.museRetention": "Значно знижені ціни на токени в обмін на дозвіл використовувати ваші промпти та відповіді для навчання майбутніх моделей Meta.", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 1b5dd50e1e07..8be6f4b20eb9 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -336,7 +336,7 @@ export const dict = { "go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。", "go.faq.a5.museRetention": "以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。", "go.faq.a5.learnMore": "了解更多", - "go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 8 月 31 日。", + "go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 9 月 30 日。", "go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,", "go.faq.a5.exceptionsLink": "以下例外情况除外", "go.faq.q6": "我可以充值余额吗?", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 6f5d12317b5b..b1e7f3a3a5dd 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -336,7 +336,7 @@ export const dict = { "go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。", "go.faq.a5.museRetention": "以允許使用您的提示詞和生成結果來訓練未來的 Meta 模型為交換,token 價格可享大幅折扣。", "go.faq.a5.learnMore": "了解更多", - "go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 8 月 31 日。", + "go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 9 月 30 日。", "go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有", "go.faq.a5.exceptionsLink": "以下例外", "go.faq.q6": "我可以儲值額度嗎?", diff --git a/packages/console/app/src/lib/inference-proxy.ts b/packages/console/app/src/lib/inference-proxy.ts index 814cc94552db..7de73a961ac8 100644 --- a/packages/console/app/src/lib/inference-proxy.ts +++ b/packages/console/app/src/lib/inference-proxy.ts @@ -1,16 +1,24 @@ import { Resource } from "@opencode-ai/console-resource" -import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" +import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" const paths: Record = { - "GET /zen/v1/models": "/v1/models", "POST /zen/v1/chat/completions": "/openai/v1/chat/completions", "POST /zen/v1/responses": "/openai/v1/responses", "POST /zen/v1/messages": "/anthropic/v1/messages", } -export async function proxyInference(request: Request, clientIP?: string): Promise { +export async function proxyInference( + request: Request, + generation: { + provider?: "openai" | "anthropic" | "google" + /** The provider's native model ID, not the public Zen alias. */ + model?: string + body: (model?: string) => ReadableStream + }, +): Promise { const url = new URL(request.url) const path = paths[`${request.method} ${url.pathname}`] ?? @@ -30,23 +38,52 @@ export async function proxyInference(request: Request, clientIP?: string): Promi // Routing only; the destination owns authentication and revocation after cutover. const workspace = await Database.use((tx) => tx - .select({ migratedAt: WorkspaceTable.migrated_at }) + .select({ + id: WorkspaceTable.id, + migratedAt: WorkspaceTable.migrated_at, + provider: ProviderTable.provider, + }) .from(KeyTable) .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) + .leftJoin( + ProviderTable, + generation.provider + ? and( + eq(ProviderTable.workspaceID, KeyTable.workspaceID), + eq(ProviderTable.provider, generation.provider), + isNull(ProviderTable.timeDeleted), + sql`length(${ProviderTable.credentials}) > 0`, + ) + : sql`false`, + ) .where(eq(KeyTable.key, key)) .limit(1) .then((rows) => rows[0]), ) if (!workspace?.migratedAt) return undefined + const model = workspace.provider ? generation.model : undefined + if (workspace.provider && !model) throw new Error("Legacy BYOK model mapping is unavailable") const destination = new URL(Resource.ConsoleMigration.inferenceUrl) - destination.pathname = `${destination.pathname.replace(/\/$/, "")}${path}` + // Imported connections must use this same workspace/provider-derived ID. + const target = model + ? `/custom/conn_${workspace.id.slice(4)}_${workspace.provider}${ + path.startsWith("/google/") + ? `/models/${encodeURIComponent(model)}${url.pathname.slice(url.pathname.lastIndexOf(":"))}` + : url.pathname.slice("/zen/v1".length) + }` + : path + destination.pathname = `${destination.pathname.replace(/\/$/, "")}${target}` destination.search = url.search destination.hash = "" - const forwarded = new Request(destination, request) + // Model extraction has already read part of the body; forward its replay stream. + const forwarded = new Request( + destination, + new Request(request, { method: request.method, body: generation.body(model) }), + ) forwarded.headers.set("authorization", `Bearer ${key}`) - const ip = request.headers.get("cf-connecting-ip") ?? clientIP + const ip = request.headers.get("cf-connecting-ip") if (ip) forwarded.headers.set("x-real-ip", ip) const requestID = request.headers.get("x-opencode-request-id") ?? request.headers.get("x-opencode-request") if (requestID) forwarded.headers.set("x-opencode-request-id", requestID) diff --git a/packages/console/app/src/middleware.ts b/packages/console/app/src/middleware.ts index e768afa4f37f..614cc87bcf00 100644 --- a/packages/console/app/src/middleware.ts +++ b/packages/console/app/src/middleware.ts @@ -2,7 +2,6 @@ import { createMiddleware } from "@solidjs/start/middleware" import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language" import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite" import { sanitizeServerActionRequest } from "~/lib/server-action" -import { proxyInference } from "~/lib/inference-proxy" export default createMiddleware({ async onRequest(event) { @@ -20,12 +19,5 @@ export default createMiddleware({ const referralCode = normalizeReferralCode(url.searchParams.get("ref")) if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode)) - - return proxyInference(event.request, event.clientAddress).catch(() => - Response.json( - { error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } }, - { status: 503, headers: { "Cache-Control": "no-store" } }, - ), - ) }, }) diff --git a/packages/console/app/src/routes/api/support/actions/reset-quota.ts b/packages/console/app/src/routes/api/support/actions/reset-quota.ts new file mode 100644 index 000000000000..6f5c2812cfe0 --- /dev/null +++ b/packages/console/app/src/routes/api/support/actions/reset-quota.ts @@ -0,0 +1,24 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Quota } from "@opencode-ai/console-core/quota.js" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" +import { Resource } from "@opencode-ai/console-resource" +import z from "zod" + +const Body = z.object({ + workspaceID: z.string().startsWith("wrk_"), + plan: z.enum(["lite", "subscription"]), +}) + +export async function POST(event: APIEvent) { + if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = Body.safeParse(await event.request.json().catch(() => undefined)) + if (!body.success) { + return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 }) + } + return Quota.reset(body.data) + .then((result) => Response.json({ success: true, message: "Usage counters reset to zero", result })) + .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })) +} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index b1aa6fe74a67..adbfdecc890f 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -50,6 +50,7 @@ import { countryFromRequest, isModelCountryRestricted } from "~/lib/request-coun import { isPeakPricing } from "./pricing" import { prepareRequestBody } from "./requestBody" import { requiresGoTrainingConsent } from "./trainingConsent" +import { proxyInference } from "~/lib/inference-proxy" type ZenData = Awaited> type PreparedBody = Awaited> @@ -100,6 +101,26 @@ export async function handler( const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp const rawZenApiKey = opts.parseApiKey(input.request.headers) const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey + const zenData = ZenData.list(opts.modelList) + if (opts.modelList === "full" && model) { + // Read routing metadata without running legacy model, auth, or balance checks. + const configured = zenData.models[model] + const entry = Array.isArray(configured) + ? configured.find((entry) => entry.formatFilter === opts.format) + : configured + const response = await proxyInference(input.request, { + provider: entry?.byokProvider, + model: entry?.providers.find((provider) => provider.id === entry.byokProvider)?.model, + body: (providerModel) => requestBody?.stream(providerModel ?? model, false) ?? body, + }).catch(() => { + void (requestBody ? requestBody.cancel() : body.cancel()).catch(() => {}) + return Response.json( + { error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } }, + { status: 503, headers: { "Cache-Control": "no-store" } }, + ) + }) + if (response) return response + } const sessionId = input.request.headers.get("x-opencode-session") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? "" const ocClient = input.request.headers.get("x-opencode-client") ?? "" @@ -112,7 +133,6 @@ export async function handler( user_agent: userAgent, "model.tier": opts.modelList === "full" ? "zen" : "go", }) - const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) const country = countryFromRequest(input.request) if (isModelCountryRestricted(modelInfo.id, country)) throw new RegionError(t("zen.api.error.countryNotAllowed")) @@ -1078,6 +1098,8 @@ export async function handler( authInfo = authInfo! const cost = centsToMicroCents(totalCostInCent) + // Keep period bounds and persisted timestamps on one snapshot when a queued write crosses a reset boundary. + const trackedAt = new Date() // For hot workspaces, batch balance/usage updates through Redis to avoid // row-level lock contention on BillingTable/UserTable. Returns the amount @@ -1118,7 +1140,7 @@ export async function handler( if (billingSource === "subscription") { const plan = authInfo.billing.subscription!.plan const black = BlackData.getLimits({ plan }) - const week = getWeekBounds(new Date()) + const week = getWeekBounds(trackedAt) const rollingWindowSeconds = black.rollingWindow * 3600 return [ db @@ -1126,11 +1148,17 @@ export async function handler( .set({ fixedUsage: sql` CASE + WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.end} THEN ${SubscriptionTable.fixedUsage} WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost} ELSE ${cost} END `, - timeFixedUpdated: sql`now()`, + timeFixedUpdated: sql` + CASE + WHEN ${SubscriptionTable.timeFixedUpdated} > ${trackedAt} THEN ${SubscriptionTable.timeFixedUpdated} + ELSE ${trackedAt} + END + `, rollingUsage: sql` CASE WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost} @@ -1154,8 +1182,8 @@ export async function handler( } if (billingSource === "lite") { const lite = LiteData.getLimits() - const week = getWeekBounds(new Date()) - const month = getMonthlyBounds(new Date(), authInfo.lite!.timeCreated) + const week = getWeekBounds(trackedAt) + const month = getMonthlyBounds(trackedAt, authInfo.lite!.timeCreated) const rollingWindowSeconds = lite.rollingWindow * 3600 const quotaCost = Math.round(cost * modelInfo.costMultiplier) return [ @@ -1164,18 +1192,30 @@ export async function handler( .set({ monthlyUsage: sql` CASE + WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.end} THEN ${LiteTable.monthlyUsage} WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.start} THEN ${LiteTable.monthlyUsage} + ${quotaCost} ELSE ${quotaCost} END `, - timeMonthlyUpdated: sql`now()`, + timeMonthlyUpdated: sql` + CASE + WHEN ${LiteTable.timeMonthlyUpdated} > ${trackedAt} THEN ${LiteTable.timeMonthlyUpdated} + ELSE ${trackedAt} + END + `, weeklyUsage: sql` CASE + WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.end} THEN ${LiteTable.weeklyUsage} WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.start} THEN ${LiteTable.weeklyUsage} + ${quotaCost} ELSE ${quotaCost} END `, - timeWeeklyUpdated: sql`now()`, + timeWeeklyUpdated: sql` + CASE + WHEN ${LiteTable.timeWeeklyUpdated} > ${trackedAt} THEN ${LiteTable.timeWeeklyUpdated} + ELSE ${trackedAt} + END + `, rollingUsage: sql` CASE WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.rollingUsage} + ${quotaCost} diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index 68c3cac69467..262a1bb349fe 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -5,14 +5,25 @@ import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { buildOptionsResponse, buildModelsResponse } from "~/routes/zen/util/modelsHandler" +import { Resource } from "@opencode-ai/console-resource" export async function OPTIONS(_input: APIEvent) { return buildOptionsResponse() } export async function GET(input: APIEvent) { + const apiKey = input.request.headers.get("authorization")?.split(" ")[1] + if (apiKey && apiKey !== "public") { + const response = await proxyModels(input, apiKey).catch(() => + Response.json( + { error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } }, + { status: 503, headers: { "Cache-Control": "no-store" } }, + ), + ) + if (response) return response + } + const disabledModels = await (() => { - const apiKey = input.request.headers.get("authorization")?.split(" ")[1] if (!apiKey) return [] as string[] return Database.use((tx) => @@ -34,3 +45,26 @@ export async function GET(input: APIEvent) { return buildModelsResponse(models) } + +async function proxyModels(input: APIEvent, apiKey: string) { + // No legacy revocation or model-policy checks before destination authentication. + const workspace = await Database.use((tx) => + tx + .select({ migratedAt: WorkspaceTable.migrated_at }) + .from(KeyTable) + .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) + .where(eq(KeyTable.key, apiKey)) + .limit(1) + .then((rows) => rows[0]), + ) + if (!workspace?.migratedAt) return undefined + + const destination = new URL(Resource.ConsoleMigration.inferenceUrl) + destination.pathname = `${destination.pathname.replace(/\/$/, "")}/v1/models` + destination.search = new URL(input.request.url).search + destination.hash = "" + const headers = new Headers({ authorization: `Bearer ${apiKey}` }) + const ip = input.request.headers.get("cf-connecting-ip") + if (ip) headers.set("x-real-ip", ip) + return fetch(destination, { headers, signal: input.request.signal, redirect: "manual" }) +} diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3409dbd254ec..23aade046bde 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.27", + "version": "1.18.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/src/quota.ts b/packages/console/core/src/quota.ts new file mode 100644 index 000000000000..856cbaba6e55 --- /dev/null +++ b/packages/console/core/src/quota.ts @@ -0,0 +1,65 @@ +import { z } from "zod" +import { and, Database, eq, isNull } from "./drizzle" +import { LiteTable, SubscriptionTable } from "./schema/billing.sql" +import { Identifier } from "./identifier" +import { fn } from "./util/fn" + +export namespace Quota { + // Zero all usage counters of one plan for a workspace, using the same + // workspace-scoped addressing as unsubscribeLite/unsubscribeBlack and + // Billing.subtractLiteUsage. Timestamps are left untouched on purpose: a + // zeroed counter with a current-window stamp reads as empty and the next + // write accumulates on top of zero, so the active windows stay undisturbed. + export const reset = fn( + z.object({ + workspaceID: Identifier.schema("workspace"), + plan: z.enum(["lite", "subscription"]), + }), + async (input) => { + if (input.plan === "lite") { + return Database.transaction(async (db) => { + const where = and(eq(LiteTable.workspaceID, input.workspaceID), isNull(LiteTable.timeDeleted)) + const rows = await db + .select({ + rollingUsage: LiteTable.rollingUsage, + weeklyUsage: LiteTable.weeklyUsage, + monthlyUsage: LiteTable.monthlyUsage, + }) + .from(LiteTable) + .where(where) + if (rows.length === 0) throw new Error("No lite usage counters found for workspace") + + await db.update(LiteTable).set({ rollingUsage: 0, weeklyUsage: 0, monthlyUsage: 0 }).where(where) + return { + plan: "lite" as const, + before: { + rollingUsage: rows.reduce((sum, row) => sum + (row.rollingUsage ?? 0), 0), + weeklyUsage: rows.reduce((sum, row) => sum + (row.weeklyUsage ?? 0), 0), + monthlyUsage: rows.reduce((sum, row) => sum + (row.monthlyUsage ?? 0), 0), + }, + } + }) + } + if (input.plan === "subscription") { + return Database.transaction(async (db) => { + const where = and(eq(SubscriptionTable.workspaceID, input.workspaceID), isNull(SubscriptionTable.timeDeleted)) + const rows = await db + .select({ rollingUsage: SubscriptionTable.rollingUsage, fixedUsage: SubscriptionTable.fixedUsage }) + .from(SubscriptionTable) + .where(where) + if (rows.length === 0) throw new Error("No subscription usage counters found for workspace") + + await db.update(SubscriptionTable).set({ rollingUsage: 0, fixedUsage: 0 }).where(where) + return { + plan: "subscription" as const, + before: { + rollingUsage: rows.reduce((sum, row) => sum + (row.rollingUsage ?? 0), 0), + fixedUsage: rows.reduce((sum, row) => sum + (row.fixedUsage ?? 0), 0), + }, + } + }) + } + throw new Error(`Unknown plan: ${input.plan}`) + }, + ) +} diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 6f1e12c6b839..3a06de763e94 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.27", + "version": "1.18.29", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 997b1d89fb8c..cb4ceb404d54 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.27", + "version": "1.18.29", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 74511617d0d7..815bfd644ae6 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 45868c0abbc6..f411779db980 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.27", + "version": "1.18.29", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 075a6ed093c6..9e565df78a1d 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -73,12 +73,28 @@ function modeName(model: ModelsDev.Model, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } +function reasoningVariants(model: ModelsDev.Model, provider: ModelsDev.Provider): ModelV2Info["variants"] { + const effort = model.reasoning_options?.find((option) => option.type === "effort") + if (!effort) return [] + const npm = model.provider?.npm ?? provider.npm + return effort.values.flatMap((value) => { + const id = value ?? "none" + // V2 variants are wire request bodies, not legacy AI SDK provider options. + const body = (() => { + if (npm === "@ai-sdk/openai") return { reasoning: { effort: id } } + if (npm === "@ai-sdk/openai-compatible") return { reasoning_effort: id } + })() + return body ? [{ id, headers: {}, body }] : [] + }) +} + function applyModel( draft: ModelV2Info, model: ModelsDev.Model, input: { readonly name?: string readonly cost?: ModelV2Info["cost"] + readonly variants?: ModelV2Info["variants"] readonly request?: NonNullable["modes"]>[string]["provider"] } = {}, ) { @@ -102,7 +118,7 @@ function applyModel( input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } - draft.variants = [] + draft.variants = input.variants ?? [] draft.time.released = released(model.release_date) draft.cost = input.cost ?? cost(model.cost) draft.status = model.status ?? "active" @@ -161,12 +177,15 @@ export const ModelsDevPlugin = define({ for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) - catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost })) + catalog.model.update(providerID, model.id, (draft) => + applyModel(draft, model, { cost: baseCost, variants: reasoningVariants(model, item) }), + ) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => applyModel(draft, model, { name: modeName(model, mode), cost: mergeCost(baseCost, options.cost), + variants: reasoningVariants(model, item), request: options.provider, }), ) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2b728ade0d82..981f833eb2b2 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -250,6 +250,7 @@ export interface Interface { sessionID: SessionSchema.ID messageID: SessionMessage.ID files?: boolean + inclusive?: boolean }) => Effect.Effect readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect @@ -942,7 +943,12 @@ const layer = Layer.effect( revert: { stage: Effect.fn("V2Session.revert.stage")(function* (input) { const session = yield* result.get(input.sessionID) - return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + return yield* SessionRevert.stage({ + session, + messageID: input.messageID, + files: input.files, + inclusive: input.inclusive, + }).pipe( Effect.provideService(Database.Service, database), Effect.provideService(EventV2.Service, events), Effect.provide(locations.get(session.location)), diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 4e7320c70482..d83e9bfa47b5 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -40,6 +40,27 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic return row === undefined ? undefined : fromRow(row) }) +export const listPending = Effect.fn("SessionInput.listPending")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + const rows = yield* db + .select() + .from(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, sessionID), + eq(SessionInputTable.delivery, "steer"), + isNull(SessionInputTable.promoted_seq), + isNull(SessionInputTable.discarded_seq), + ), + ) + .orderBy(asc(SessionInputTable.admitted_seq)) + .all() + .pipe(Effect.orDie) + return rows.map(fromRow) +}) + export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { id: SessionMessage.ID, }) {} diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f848748ffce4..49dc7015c008 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -11,6 +11,7 @@ import { WorkspaceTable } from "../control-plane/workspace.sql" import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" +import { commitReplacement } from "./revert-replacement" import { WorkspaceV2 } from "../workspace" import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" @@ -458,6 +459,22 @@ const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.RevertEvent.Committed, (event) => Effect.gen(function* () { + const session = yield* db + .select({ revert: SessionTable.revert }) + .from(SessionTable) + .where(eq(SessionTable.id, event.data.sessionID)) + .get() + .pipe(Effect.orDie) + if (session?.revert?.inclusive) { + yield* commitReplacement(db, event.data.sessionID, session.revert) + yield* db + .update(SessionTable) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + return + } const boundary = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) diff --git a/packages/core/src/session/revert-replacement.ts b/packages/core/src/session/revert-replacement.ts new file mode 100644 index 000000000000..0882cedaff0b --- /dev/null +++ b/packages/core/src/session/revert-replacement.ts @@ -0,0 +1,52 @@ +import { and, eq, gte, isNull, lte, or } from "drizzle-orm" +import { Effect } from "effect" +import type { Database } from "../database/database" +import type { SessionSchema } from "./schema" +import { SessionInputTable, SessionMessageTable } from "./sql" + +// Replacement is opt-in; the upstream revert contract retains its boundary. +export const commitReplacement = Effect.fn("SessionRevert.commitReplacement")(function* ( + db: Database.Interface["db"], + sessionID: SessionSchema.ID, + revert: NonNullable, +) { + const message = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.id, revert.messageID))) + .get() + .pipe(Effect.orDie) + const input = yield* db + .select() + .from(SessionInputTable) + .where(and(eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.id, revert.messageID))) + .get() + .pipe(Effect.orDie) + if (!message && (!input || input.delivery !== "steer" || input.promoted_seq !== null || input.discarded_seq !== null)) + return yield* Effect.die(`Replacement boundary not found: ${revert.messageID}`) + if (message) + yield* db + .delete(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), gte(SessionMessageTable.seq, message.seq))) + .run() + .pipe(Effect.orDie) + yield* db + .delete(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, sessionID), + or( + message ? gte(SessionInputTable.promoted_seq, message.seq) : undefined, + and( + eq(SessionInputTable.delivery, "steer"), + isNull(SessionInputTable.promoted_seq), + gte(SessionInputTable.admitted_seq, input?.admitted_seq ?? message!.seq), + // Do not discard the replacement admitted after staging, or explicit queues. + lte(SessionInputTable.admitted_seq, revert.inputThroughSeq ?? 0), + ), + ), + ), + ) + .run() + .pipe(Effect.orDie) +}) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 9999d5da5a04..595e14342385 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -7,6 +7,7 @@ import { EventV2 } from "../event" import { RelativePath } from "../schema" import { Snapshot } from "../snapshot" import { SessionEvent } from "./event" +import { SessionInput } from "./input" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" import { SessionMessageTable } from "./sql" @@ -22,6 +23,7 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass() + return yield* new MessageNotFoundError(input) + } const rows = yield* db .select() .from(SessionMessageTable) @@ -61,13 +73,16 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { readonly session: SessionSchema.Info readonly messageID: SessionMessage.ID readonly files?: boolean + readonly inclusive?: boolean }) { + const db = (yield* Database.Service).db + const inputThroughSeq = input.inclusive ? yield* EventV2.latestSequence(db, input.session.id) : undefined const snapshot = yield* Snapshot.Service const events = yield* EventV2.Service const original = input.session.revert?.snapshot ? Snapshot.ID.make(input.session.revert.snapshot) : yield* snapshot.capture() - const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID }) + const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID, inclusive: input.inclusive }) const restore = new Map() if (original) { for (const file of input.session.revert?.files ?? []) restore.set(file.path, original) @@ -80,6 +95,8 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { : [] const revert = { messageID: input.messageID, + inclusive: input.inclusive, + inputThroughSeq, snapshot: original, diff: files .map((file) => file.patch) diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index 504b29ede961..ce7ac26db306 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -7,6 +7,7 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Shell } from "@opencode-ai/core/shell" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -14,13 +15,7 @@ const directory = AbsolutePath.make(process.cwd()) const it = testEffect( AppNodeBuilder.build(CommandV2.node, [ [Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))], - [ - Location.node, - Layer.succeed( - Location.Service, - Location.Service.of(location(Location.Ref.make({ directory }))), - ), - ], + [Location.node, Layer.succeed(Location.Service, Location.Service.of(location(Location.Ref.make({ directory }))))], ]), ) @@ -80,7 +75,12 @@ describe("CommandV2", () => { const command = yield* CommandV2.Service yield* command.transform((editor) => { editor.update("review", (item) => { - item.template = "First=$1 Rest=$2 Shell=!`printf ' value '; printf 'ignored' >&2`" + item.template = + "First=$1 Rest=$2 Shell=!`" + + (Shell.ps(Shell.preferred() ?? "") + ? "[Console]::Out.Write(' value '); [Console]::Error.Write('ignored')" + : "printf ' value '; printf 'ignored' >&2") + + "`" item.source = "command" }) }) diff --git a/packages/core/test/fixture/mcp-stdio.ts b/packages/core/test/fixture/mcp-stdio.ts index 1cd199e30ab7..f21461d676fa 100644 --- a/packages/core/test/fixture/mcp-stdio.ts +++ b/packages/core/test/fixture/mcp-stdio.ts @@ -1,6 +1,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import path from "node:path" const server = new Server({ name: "core-mcp-stdio-test", version: "1.0.0" }, { capabilities: { prompts: {} } }) @@ -14,7 +15,7 @@ server.setRequestHandler(GetPromptRequestSchema, (request) => messages: [ { role: "user", - content: { type: "text", text: `${process.cwd()}/${request.params.arguments?.suffix ?? ""}` }, + content: { type: "text", text: path.join(process.cwd(), request.params.arguments?.suffix ?? "") }, }, ], }), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 6c0f7e070296..7f475298beba 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -27,6 +27,86 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no const it = testEffect(layer) describe("ModelsDevPlugin", () => { + for (const input of [ + { + name: "Muse Spark Responses", + npm: "@ai-sdk/openai", + values: ["minimal", "high", "xhigh"], + bodies: [ + { reasoning: { effort: "minimal" } }, + { reasoning: { effort: "high" } }, + { reasoning: { effort: "xhigh" } }, + ], + }, + { + name: "inherited Chat API", + npm: "@ai-sdk/openai-compatible", + values: [null, "high"], + bodies: [{ reasoning_effort: "none" }, { reasoning_effort: "high" }], + }, + { + name: "model transport override", + npm: "@ai-sdk/openai-compatible", + provider: { npm: "@ai-sdk/openai" }, + values: ["high"], + bodies: [{ reasoning: { effort: "high" } }], + }, + { name: "explicitly empty controls", npm: "@ai-sdk/openai", values: [], bodies: [] }, + { name: "unsupported transport", npm: "unknown-sdk", values: ["high"], bodies: [] }, + ]) { + it.effect("projects declared reasoning efforts for " + input.name, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + yield* ModelsDevPlugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( + Effect.provideService( + ModelsDev.Service, + ModelsDev.Service.of({ + get: () => + Effect.succeed({ + acme: { + id: "acme", + name: "Acme", + env: [], + npm: input.npm, + api: "https://acme.test/v1", + models: { + muse: { + id: "muse", + name: "Muse Spark", + release_date: "2026-09-01", + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + provider: input.provider, + limit: { context: 100000, output: 4096 }, + reasoning_options: [{ type: "effort", values: input.values }], + experimental: { modes: { fast: { provider: { body: { service_tier: "priority" } } } } }, + }, + }, + }, + }), + refresh: () => Effect.void, + }), + ), + ) + for (const id of ["muse", "muse-fast"]) { + const model = yield* catalog.model.get(ProviderV2.ID.make("acme"), ModelV2.ID.make(id)) + expect(model?.variants).toEqual( + input.bodies.map((body, index) => ({ + id: ModelV2.VariantID.make(input.values[index] ?? "none"), + headers: {}, + body, + })), + ) + } + }), + ) + } + it.effect("projects models.dev modes as separate models instead of variants", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 822ad497c2f2..976dc609f8e9 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -24,7 +24,10 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Config } from "@opencode-ai/core/config" +import { Shell } from "@opencode-ai/core/shell" import { SessionInputPayload } from "@opencode-ai/schema/session-input-payload" +import os from "node:os" +import path from "node:path" import { testEffect } from "./lib/effect" const executionCalls: SessionV2.ID[] = [] @@ -186,18 +189,58 @@ describe("SessionV2.prompt", () => { .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.shell({ sessionID, command: "printf current-shell-output" }) + const command = Shell.ps(Shell.preferred() ?? "") + ? "[Console]::Out.Write('current-shell-output')" + : "printf current-shell-output" + yield* session.shell({ sessionID, command }) expect(yield* session.messages({ sessionID, order: "asc" })).toMatchObject([ { type: "shell", - command: "printf current-shell-output", + command, output: "current-shell-output", }, ]) }), ) + it.effect("pending UI inputs exclude queued, discarded and promoted messages", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const database = yield* Database.Service + const events = yield* EventV2.Service + const steer = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "keep visible" }), resume: false }) + const removed = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "discard me" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: Prompt.make({ text: "queue separately" }), + delivery: "queue", + resume: false, + }) + expect((yield* SessionInput.listPending(database.db, sessionID)).map((input) => input.id)).toEqual([ + steer.id, + removed.id, + ]) + yield* session.interrupt(sessionID) + expect((yield* SessionInput.listPending(database.db, sessionID)).map((input) => input.id)).toEqual([ + steer.id, + removed.id, + ]) + yield* database.db + .update(SessionInputTable) + .set({ discarded_seq: 99 }) + .where(eq(SessionInputTable.id, removed.id)) + .run() + .pipe(Effect.orDie) + expect((yield* SessionInput.listPending(database.db, sessionID)).map((input) => input.id)).toEqual([steer.id]) + expect(yield* session.messages({ sessionID })).toEqual([]) + yield* SessionInput.promoteSteers(database.db, events, sessionID, Number.MAX_SAFE_INTEGER) + expect(yield* SessionInput.listPending(database.db, sessionID)).toEqual([]) + expect(yield* session.messages({ sessionID })).toMatchObject([{ id: steer.id, text: "keep visible" }]) + }), + ) + it.effect("durably admits one user message before transcript promotion", () => Effect.gen(function* () { yield* setup @@ -701,10 +744,16 @@ describe("SessionV2.command", () => { const command = yield* CommandV2.Service.pipe(Effect.provide(locations.get(location))) const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(location))) yield* agents.transform((draft) => draft.update(AgentV2.ID.make("build"), () => undefined)) - const sideEffect = `/tmp/opencode-command-retry-${crypto.randomUUID()}` + const sideEffect = path.join(os.tmpdir(), `opencode-command-retry-${crypto.randomUUID()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => Bun.file(sideEffect).delete()).pipe(Effect.ignore)) yield* command.transform((draft) => draft.update("retry-safe", (item) => { - item.template = `!` + "`" + `printf x >> '${sideEffect}'; printf expanded` + "`" + item.template = + "!`" + + (Shell.ps(Shell.preferred() ?? "") + ? `[IO.File]::AppendAllText('${sideEffect.replaceAll("'", "''")}', 'x'); [Console]::Out.Write('expanded')` + : `printf x >> '${sideEffect.replaceAll("'", "'\\''")}'; printf expanded`) + + "`" }), ) const id = SessionMessage.ID.create() @@ -752,7 +801,6 @@ describe("SessionV2.command", () => { expect(conflict._tag).toBe("Session.PromptConflictError") expect(yield* Effect.promise(() => Bun.file(sideEffect).text())).toBe("x") expect(yield* eventCount("session.next.command.executed.1")).toBe(1) - yield* Effect.promise(() => Bun.file(sideEffect).delete()) }), ) @@ -964,9 +1012,7 @@ describe("SessionV2.queue", () => { id: second.id, discardedSeq: expect.any(Number), }) - expect( - (yield* session.history({ sessionID, limit: 20 })).events.map((event) => event.type), - ).toEqual([ + expect((yield* session.history({ sessionID, limit: 20 })).events.map((event) => event.type)).toEqual([ "session.next.prompt.admitted", "session.next.prompt.admitted", "session.next.prompt.revised", diff --git a/packages/core/test/session-replacement.test.ts b/packages/core/test/session-replacement.test.ts new file mode 100644 index 000000000000..51b3445396bf --- /dev/null +++ b/packages/core/test/session-replacement.test.ts @@ -0,0 +1,136 @@ +import { expect } from "bun:test" +import { DateTime, Effect, Schema } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) + +for (const promoted of [true, false]) { + it.effect( + `replacement removes its ${promoted ? "promoted" : "pending"} boundary but preserves queues and new admission`, + () => + Effect.gen(function* () { + const database = yield* Database.Service + const events = yield* EventV2.Service + const sessionID = SessionV2.ID.make("ses_replacement") + const id = SessionMessage.ID.make("msg_original") + yield* database.db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* database.db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const otherSessionID = SessionV2.ID.make("ses_unrelated") + yield* database.db + .insert(SessionTable) + .values({ + id: otherSessionID, + project_id: Project.ID.global, + slug: "unrelated", + directory: "/project", + title: "unrelated", + version: "test", + }) + .run() + yield* database.db + .insert(SessionInputTable) + .values([ + { + id, + session_id: sessionID, + prompt: { text: "original" }, + delivery: "steer", + admitted_seq: 1, + promoted_seq: promoted ? 2 : null, + }, + { + id: SessionMessage.ID.make("msg_later"), + session_id: sessionID, + prompt: { text: "later steer" }, + delivery: "steer", + admitted_seq: 3, + }, + { + id: SessionMessage.ID.make("msg_queue"), + session_id: sessionID, + prompt: { text: "queue" }, + delivery: "queue", + admitted_seq: 4, + }, + { + id: SessionMessage.ID.make("msg_at_cutoff"), + session_id: sessionID, + prompt: { text: "last pre-stage steer" }, + delivery: "steer", + admitted_seq: 5, + }, + { + id: SessionMessage.ID.make("msg_unrelated"), + session_id: otherSessionID, + prompt: { text: "other session" }, + delivery: "steer", + admitted_seq: 3, + }, + { + id: SessionMessage.ID.make("msg_replacement"), + session_id: sessionID, + prompt: { text: "replacement" }, + delivery: "steer", + admitted_seq: 6, + }, + ]) + .run() + if (promoted) + yield* database.db + .insert(SessionMessageTable) + .values({ + id, + session_id: sessionID, + seq: 2, + type: "user", + time_created: 1, + data: Schema.encodeSync(SessionMessage.User)( + SessionMessage.User.make({ + id, + type: "user", + text: "original", + time: { created: DateTime.makeUnsafe(1) }, + }), + ), + }) + .run() + const revert = { messageID: id, inclusive: true, inputThroughSeq: 5 } + yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, timestamp: DateTime.makeUnsafe(1), revert }) + expect((yield* database.db.select().from(SessionInputTable).all()).length).toBe(6) + yield* events.publish(SessionEvent.RevertEvent.Committed, { + sessionID, + messageID: id, + timestamp: DateTime.makeUnsafe(2), + }) + expect(yield* database.db.select().from(SessionMessageTable).all()).toEqual([]) + expect((yield* database.db.select().from(SessionInputTable).all()).map((row) => String(row.id)).sort()).toEqual( + ["msg_queue", "msg_replacement", "msg_unrelated"], + ) + }), + ) +} diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 8e1db92e0bce..6fb5c59d1461 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1726,6 +1726,54 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("replaces a staged user prompt before the next provider turn without repeating it", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const boundary = SessionMessage.ID.create() + yield* session.prompt({ + id: boundary, + sessionID, + prompt: Prompt.make({ text: "boundary" }), + resume: false, + }) + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + revert: { + messageID: boundary, + files: [], + inclusive: true, + inputThroughSeq: yield* session.latestSequence(sessionID), + }, + }) + const steer = yield* session.prompt({ + sessionID, + prompt: Prompt.make({ text: "steer after revert" }), + resume: false, + }) + response = fragmentFixture("text", "text-after-steer-revert", ["done"]).completeEvents + + yield* session.resume(sessionID) + + expect(yield* session.context(sessionID)).toMatchObject([ + { id: steer.id, type: "user", text: "steer after revert" }, + { type: "assistant", content: [{ type: "text", text: "done" }] }, + ]) + const history = yield* session.history({ sessionID, limit: 100 }) + const committed = history.events.findIndex((event) => event.type === "session.next.revert.committed") + const promoted = history.events.findIndex( + (event) => + event.type === "session.next.prompted" && "messageID" in event.data && event.data.messageID === steer.id, + ) + expect(committed).toBeGreaterThan(-1) + expect(promoted).toBeGreaterThan(committed) + }), + ) + it.effect("projects a payload-controlled queued tool continuation into the consumer timeline and history", () => Effect.gen(function* () { yield* setup diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d134d4c538ab..e652a6a188df 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 75dfd6e73d52..0ffa670143c6 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.27", + "version": "1.18.29", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index e60ad3db4ddf..819f7c6d10e4 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.27", + "version": "1.18.29", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 3fccb804a978..038a958579bb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.27", + "version": "1.18.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index eef35ddd9584..b8dfeca3d2c8 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.27", + "version": "1.18.29", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 5cf36ff2869e..c12a30fe0c93 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.27", + "version": "1.18.29", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index accb982acf9e..7e541f197337 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.27", + "version": "1.18.29", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 5618afd886d9..cac8579d7ab0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,13 +1,13 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.27", + "version": "1.18.29", "name": "opencode", "type": "module", "license": "MIT", "private": true, "scripts": { "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000 --only-failures", + "test": "bun test --timeout 30000 --max-concurrency 2 --only-failures", "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 2ae34ac42f84..6ead52c81328 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -297,8 +297,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug if (ALLOWED_MODELS.has(model.api.id)) return true if (DISALLOWED_MODELS.has(model.api.id)) return false if (model.api.id === "gpt-5.6") return false - const match = model.api.id.match(/^gpt-(\d+\.\d+)/) - return match ? parseFloat(match[1]) > 5.4 : false + const match = model.api.id.match(/^gpt-(\d+)(?:\.(\d+))?/) + if (!match) return false + const major = Number(match[1]) + const minor = Number(match[2] ?? 0) + return major > 5 || (major === 5 && minor > 4) }) .map(([modelID, model]) => [ modelID, diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index fbc9df593289..159f7806a75e 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -326,6 +326,41 @@ describe("plugin.codex", () => { ) }) + test.each([ + ["gpt-6-astra", true], + ["gpt-6", true], + ["gpt-6.0-astra", true], + ["gpt-7", true], + ["gpt-10", true], + ["gpt-5.5-astra", true], + ["gpt-5.9", true], + ["gpt-5.10", true], + ["gpt-5.10-astra", true], + ["gpt-5.40", true], + ["gpt-5", false], + ["gpt-5.4-astra", false], + ["gpt-5.04-astra", false], + ["gpt-4.1", false], + ["gpt-4.99", false], + ["gpt-5.5-pro", false], + ["gpt-5.6", false], + ["gpt-6garbage", true], + ["gpt-6.", true], + ["gpt-6.1.2", true], + ["not-a-gpt-model", false], + ])("filters OAuth model %s by GPT major and minor versions", async (id, allowed) => { + const hooks = await CodexAuthPlugin({} as never) + const provider = { + models: { + [id]: { id, api: { id }, limit: {}, cost: {}, options: {} }, + }, + } + + const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never) + + expect(Object.keys(models)).toEqual(allowed ? [id] : []) + }) + test("deduplicates concurrent Codex token refreshes", async () => { const refreshedAccess = createTestJwt({ "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 6516a5d07c27..57e81624deca 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -176,7 +176,7 @@ const scenarios: Scenario[] = [ .status(400), http.protected.get("/config/providers", "config.providers").json(), http.protected.get("/project", "project.list").json(200, array, "status"), - http.protected.get("/api/project", "v2.project.list").json(200, array, "status"), + http.protected.get("/api/project", "v2.project.list").json(200, locationData(array), "status"), http.protected.get("/project/current", "project.current").json( 200, (body, ctx) => { @@ -189,7 +189,10 @@ const scenarios: Scenario[] = [ 200, (body, ctx) => { object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + object(body.location) + object(body.location.project) + object(body.data) + check(body.data.worktree === ctx.directory, "current project should resolve from scenario directory") }, "status", ), @@ -251,7 +254,7 @@ const scenarios: Scenario[] = [ path: route("/api/project/{projectID}/directories", { projectID: ctx.state.id }), headers: ctx.headers(), })) - .json(200, array, "status"), + .json(200, locationData(array), "status"), http.protected .post("/experimental/project/{projectID}/copy/generate-name", "experimental.projectCopy.generateName") .seeded((ctx) => ctx.project()) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 87fefc959d60..6af058c72090 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index c198016edbf4..877937ac411c 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -29,6 +29,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") query: SessionMessagesQuery, success: Schema.Struct({ data: Schema.Array(SessionMessage.Message), + pending: Schema.optional(Schema.Array(SessionMessage.User)), throughSeq: NonNegativeInt, cursor: Schema.Struct({ previous: Schema.String.pipe(Schema.optional), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d91d495f5869..4d539c3c3118 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -432,7 +432,11 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { params: { sessionID: Session.ID }, - payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), + payload: Schema.Struct({ + messageID: SessionMessage.ID, + files: Schema.Boolean.pipe(Schema.optional), + inclusive: Schema.Boolean.pipe(Schema.optional), + }), success: Schema.Struct({ data: Revert.State }), error: [MessageNotFoundError, SessionNotFoundError, UnknownError], }) diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts index 05222d539819..dc73d5a68aa3 100644 --- a/packages/schema/src/revert.ts +++ b/packages/schema/src/revert.ts @@ -16,6 +16,8 @@ export interface FileDiff extends Schema.Schema.Type {} export const State = Schema.Struct({ messageID: SessionMessage.ID, + inclusive: Schema.Boolean.pipe(optional), + inputThroughSeq: NonNegativeInt.pipe(optional), partID: Schema.String.pipe(optional), snapshot: Schema.String.pipe(optional), diff: Schema.String.pipe(optional), diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3b4572544163..e6fa0addcc14 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d5a5d60ac5fe..5587e7b775ae 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3169,6 +3169,7 @@ export class Revert extends HeyApiClient { sessionID: string messageID?: string files?: boolean + inclusive?: boolean }, options?: Options, ) { @@ -3180,6 +3181,7 @@ export class Revert extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "body", key: "messageID" }, { in: "body", key: "files" }, + { in: "body", key: "inclusive" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index eb343970b9f7..cc675c0d4b93 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2909,6 +2909,7 @@ export type SessionDurableEventStream = string export type SessionMessagesResponse = { data: Array + pending?: Array throughSeq: number cursor: { previous?: string @@ -3595,6 +3596,8 @@ export type FileDiff = { export type RevertState = { messageID: string + inclusive?: boolean + inputThroughSeq?: number partID?: string snapshot?: string diff?: string @@ -12969,6 +12972,7 @@ export type V2SessionRevertStageData = { body: { messageID: string files?: boolean + inclusive?: boolean } path: { sessionID: string diff --git a/packages/server/package.json b/packages/server/package.json index a379a5f06f25..3c016338dc33 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.27", + "version": "1.18.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index 358ad808c377..cc5bf3718e9c 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -1,10 +1,13 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionInput } from "@opencode-ai/core/session/input" +import { Database } from "@opencode-ai/core/database/database" import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" import { firstPreparedMessageID, stripRepeatedPreparedContext } from "./prepared-context" +import { pendingInputMessages } from "./pending-inputs" const DefaultMessagesLimit = 50 @@ -28,6 +31,7 @@ const cursor = { export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service + const database = yield* Database.Service return handlers.handle( "session.messages", @@ -88,8 +92,10 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl const projected = messages.map((message) => stripRepeatedPreparedContext(message, firstPreparedID)) const first = projected[0] const last = projected.at(-1) + const pending = pendingInputMessages(yield* SessionInput.listPending(database.db, ctx.params.sessionID)) return { data: projected, + pending, throughSeq, cursor: { previous: first ? cursor.encode(first, order, "previous") : undefined, diff --git a/packages/server/src/handlers/pending-inputs.ts b/packages/server/src/handlers/pending-inputs.ts new file mode 100644 index 000000000000..741a6fb1da19 --- /dev/null +++ b/packages/server/src/handlers/pending-inputs.ts @@ -0,0 +1,17 @@ +import { SessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage } from "@opencode-ai/core/session/message" + +// This is a UI projection only. The runner still owns transcript promotion. +export function pendingInputMessages(inputs: readonly SessionInput.Admitted[]) { + return inputs.map((input) => + SessionMessage.User.make({ + id: input.id, + type: "user", + text: input.prompt.text, + files: input.prompt.files, + agents: input.prompt.agents, + payload: input.payload, + time: { created: input.timeCreated }, + }), + ) +} diff --git a/packages/server/test/pending-inputs.test.ts b/packages/server/test/pending-inputs.test.ts new file mode 100644 index 000000000000..c035f8fc2229 --- /dev/null +++ b/packages/server/test/pending-inputs.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { SessionInput } from "@opencode-ai/core/session/input" +import { Schema } from "effect" +import { pendingInputMessages } from "../src/handlers/pending-inputs" + +test("projects admitted inputs without dropping prompt content or changing their identity", () => { + const input = Schema.decodeUnknownSync(SessionInput.Admitted)({ + id: "msg_pending", + sessionID: "ses_pending", + admittedSeq: 3, + prompt: { text: "Preserve this", files: [{ uri: "data:text/plain;base64,aGk=", mime: "text/plain" }] }, + delivery: "steer", + timeCreated: 1, + }) + const [message] = pendingInputMessages([input]) + expect(message).toMatchObject({ id: input.id, type: "user", text: input.prompt.text, files: input.prompt.files }) + expect(message?.time.created).toEqual(input.timeCreated) + expect(pendingInputMessages([])).toEqual([]) +}) diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index c9513628eff9..0c1c656bb503 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.27", + "version": "1.18.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 9f4c08c9ae28..deb856fd5c6e 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.27", + "version": "1.18.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 2e71201a44c5..5340918954d4 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.27", + "version": "1.18.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx index 4966d5d45314..e4c2ac5cbdd3 100644 --- a/packages/stats/app/src/component/model-compare-detail.tsx +++ b/packages/stats/app/src/component/model-compare-detail.tsx @@ -24,6 +24,8 @@ import { findModelCatalogEntry, formatCatalogLabName, getModelCatalog, + isKnownCatalogLab, + isProviderlessLab, type ModelCatalog, type ModelCatalogEntry, } from "../routes/model-catalog" @@ -71,7 +73,7 @@ const comparisonModelLimit = 6 type ComparisonModel = { name: string lab: string - labName: string + labName?: string slug: string catalog: ModelCatalogEntry | null stats: StatsModelComparisonEntry | null @@ -159,13 +161,19 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp let comparisonBodyScroll: HTMLDivElement | undefined const models = createMemo(() => modelSelections().map((model, index) => - buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), + buildComparisonModel( + model.lab, + model.slug, + model.catalog ?? null, + stats()?.models[index] ?? null, + catalog()?.labs.map((lab) => lab.id) ?? [], + ), ), ) const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - AI Model Comparison`) const description = createMemo( () => - `Compare ${models()[0].name} from ${models()[0].labName} and ${models()[1].name} from ${models()[1].labName} on key metrics including benchmarks, price, context length, usage, and model features.`, + `Compare ${comparisonModelLabel(models()[0])} and ${comparisonModelLabel(models()[1])} on key metrics including benchmarks, price, context length, usage, and model features.`, ) const canonicalPath = createMemo(() => { if (props.family) return canonicalFamilyComparisonPath(props.family.first, props.family.second) @@ -206,7 +214,7 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp "@type": "SoftwareApplication", name: model.name, applicationCategory: "AI model", - provider: model.labName, + ...(model.labName ? { provider: model.labName } : {}), })), }), ) @@ -459,7 +467,9 @@ function CompareDetailSelectButton(props: { aria-expanded={props.expanded} onClick={props.onOpen} > - + + {(labName) => } + {props.model.name} @@ -580,10 +590,7 @@ function CompareModelDetail(props: { model: ModelCatalogEntry }) { {props.model.name}
-

- {props.model.description ?? - `${props.model.name} is an AI model from ${formatCatalogLabName(props.model.lab)}.`} -

+

{props.model.description ?? `${props.model.name} is an AI model.`}

@@ -789,9 +796,11 @@ function LabLogo(props: { lab: string; label: string; size: "large" | "small" | const iconId = () => getProviderIconId(props.lab) return ( - - + + + + ) } @@ -832,11 +841,13 @@ function buildComparisonModel( modelParam: string, catalog: ModelCatalogEntry | null, stats: StatsModelComparisonEntry | null, + catalogLabs: readonly string[], ): ComparisonModel { + const lab = catalog?.lab ?? stats?.provider ?? catalogSlug(labParam) return { name: catalog?.name ?? stats?.model ?? formatParamName(modelParam), - lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam), - labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam), + lab, + labName: isKnownCatalogLab(lab, catalogLabs) ? formatCatalogLabName(lab) : undefined, slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam), catalog, stats, @@ -875,10 +886,7 @@ function buildComparisonDetailSections(models: readonly ComparisonModel[]): Comp { title: "Overview", rows: [ - comparisonDetailRow( - "Author", - models.map((model) => linkedTextCell(model.stats?.author ?? model.labName, labHref(model.lab))), - ), + comparisonDetailRow("Author", models.map(providerDetailCell)), comparisonDetailRow( "Context length", models.map((model) => limitCell(model.catalog?.limit?.context)), @@ -897,10 +905,7 @@ function buildComparisonDetailSections(models: readonly ComparisonModel[]): Comp "Output modalities", models.map((model) => textCell(formatCatalogModalities(model.catalog?.modalities.output ?? []))), ), - comparisonDetailRow( - "Providers", - models.map((model) => linkedTextCell(model.labName, labHref(model.lab))), - ), + comparisonDetailRow("Providers", models.map(providerDetailCell)), ], }, { @@ -1013,6 +1018,15 @@ function comparisonRef(model: ComparisonModel): ComparisonModelRef { } } +function comparisonModelLabel(model: ComparisonModel) { + return model.labName ? `${model.name} from ${model.labName}` : model.name +} + +function providerDetailCell(model: ComparisonModel): ComparisonDetailCell { + if (!model.labName) return textCell("") + return linkedTextCell(model.stats?.author ?? model.labName ?? "", labHref(model.lab)) +} + function textCell(value: string): ComparisonDetailCell { return { value } } diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 977660a83c80..6753afa3cb42 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -17,7 +17,13 @@ import { LocaleLinks } from "../../component/locale-links" import { useI18n } from "../../context/i18n" import { useLanguage } from "../../context/language" import { localizedUrl } from "../../lib/language" -import { findModelCatalogEntry, formatCatalogLabName, loadModelCatalog, type ModelCatalogEntry } from "../model-catalog" +import { + findModelCatalogEntry, + formatCatalogLabName, + isKnownCatalogLab, + loadModelCatalog, + type ModelCatalogEntry, +} from "../model-catalog" import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" @@ -96,7 +102,9 @@ export default function StatsModel() { const modelName = createMemo( () => catalogEntry()?.name ?? publicModelName(canonicalModel()) ?? i18n.t("model.fallback"), ) - const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam())) + const lab = createMemo(() => catalogEntry()?.lab ?? stats()?.provider ?? labParam()) + const catalogLabs = createMemo(() => page()?.catalog.labs.map((item) => item.id) ?? []) + const labName = createMemo(() => (isKnownCatalogLab(lab(), catalogLabs()) ? formatCatalogLabName(lab()) : undefined)) const formerName = createMemo(() => formerModelName(canonicalModel())) const searchModelName = createMemo(() => (formerName() ? `${modelName()} (formerly ${formerName()})` : modelName())) const modelTitle = createMemo(() => i18n.t("model.title", { model: searchModelName() })) @@ -185,9 +193,14 @@ export default function StatsModel() { - + props.catalog?.lab ?? props.data?.provider ?? props.labName + const labId = () => props.catalog?.lab ?? props.data?.provider + const hasLab = () => props.labName !== undefined const modelName = () => props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback") const weights = () => props.catalog?.weights[0] const labs = () => props.catalogData?.labs ?? [] @@ -281,35 +295,28 @@ function ModelHero(props: { Data - / - 0} - fallback={ - - {props.labName} - - - } - > - ({ - href: language.route(`${import.meta.env.BASE_URL}${lab.id}`), - label: lab.name, - value: lab.id, - }))} - value={providerSlug(labId())} - variant="model" - /> + + / + 0} fallback={{props.labName}}> + ({ + href: language.route(`${import.meta.env.BASE_URL}${lab.id}`), + label: lab.name, + value: lab.id, + }))} + value={providerSlug(labId() ?? "")} + variant="model" + /> + / 0} fallback={ - - {modelName()} - + + {modelName()} } > @@ -328,9 +335,11 @@ function ModelHero(props: {
- - + + + +

{modelName()}

@@ -980,7 +989,7 @@ function GeoCountryList(props: { ) } -function ModelPeersSection(props: { data: StatsModelPageData | null }) { +function ModelPeersSection(props: { data: StatsModelPageData | null; catalogLabs: readonly string[] }) { const i18n = useI18n() return (
@@ -993,7 +1002,9 @@ function ModelPeersSection(props: { data: StatsModelPageData | null }) { >
    - {(peer) => } + {(peer) => ( + + )}
@@ -1011,23 +1022,29 @@ function MetricCard(props: { label: string; value: string; detail?: string; stat ) } -function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) { +function PeerRow(props: { peer: ModelPeerEntry; active: boolean; catalogLabs: readonly string[] }) { const language = useLanguage() + const hasProvider = () => isKnownCatalogLab(props.peer.provider, props.catalogLabs) return (
  • {String(props.peer.rank).padStart(2, "0")} - - + + + + {props.peer.model} - {props.peer.author} + + {props.peer.author} + {formatTokens(props.peer.tokens)} @@ -1050,6 +1067,7 @@ function ModelEmptyState(props: { title: string; description: string; compact?: function modelComparisonPairs( catalogModels: ModelCatalogOption[] | undefined, + catalogLabs: readonly string[], catalogEntry: ModelCatalogEntry | null, data: StatsModelPageData | null, ) { @@ -1064,7 +1082,7 @@ function modelComparisonPairs( name: peer.model, lab: peer.provider, slug: peer.slug, - labName: peer.author, + labName: isKnownCatalogLab(peer.provider, catalogLabs) ? peer.author : undefined, metric: `#${peer.rank} / ${formatTokens(peer.tokens)}`, }, detail: "Usage peer", @@ -1090,7 +1108,7 @@ function modelComparisonRef( name: data.model, lab: data.provider, slug: data.slug, - labName: data.author, + labName: undefined, metric: `#${data.rank}`, } } diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx index 24077bc3b9c4..7861f9b32fa1 100644 --- a/packages/stats/app/src/routes/compare-cards.tsx +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -119,17 +119,24 @@ function ComparisonCardIcon() { } function ComparisonPanelCard(props: { pair: ComparisonPair }) { + const firstLabName = () => props.pair.first.labName + const secondLabName = () => props.pair.second.labName + return ( {props.pair.detail} {props.pair.first.name} vs {props.pair.second.name} -

    - {props.pair.first.labName ?? formatCatalogLabName(props.pair.first.lab)} - - {props.pair.second.labName ?? formatCatalogLabName(props.pair.second.lab)} -

    + +

    + {(name) => {name()}} + + + + {(name) => {name()}} +

    +
    {props.pair.first.metric ?? "Listed"} / {props.pair.second.metric ?? "Listed"} @@ -143,14 +150,11 @@ function ComparisonLabLogo(props: { model: ComparisonModelRef }) { const iconId = () => providerIconId(props.model.lab) return ( - - + + + + ) } diff --git a/packages/stats/app/src/routes/compare-radar.tsx b/packages/stats/app/src/routes/compare-radar.tsx index d7403809ac87..35ada12885f1 100644 --- a/packages/stats/app/src/routes/compare-radar.tsx +++ b/packages/stats/app/src/routes/compare-radar.tsx @@ -9,7 +9,7 @@ const toolUseBenchmarkPattern = /(terminal bench|claw eval|tau ?(?:bench|2|3))/ export type ComparisonRadarModel = { name: string - labName: string + labName?: string catalog: ModelCatalogEntry | null } @@ -61,7 +61,7 @@ export function ComparisonRadar(props: ComparisonRadarProps) {