From 7e9140832eb69f2aa85746f67fe9ac5854065a17 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 11:02:22 -0300 Subject: [PATCH 01/10] chore: keep transient audit notes outside the repository --- docs/fork/web-audit.md | 437 ----------------------------------------- 1 file changed, 437 deletions(-) delete mode 100644 docs/fork/web-audit.md diff --git a/docs/fork/web-audit.md b/docs/fork/web-audit.md deleted file mode 100644 index 9c4e4a9ea2fc..000000000000 --- a/docs/fork/web-audit.md +++ /dev/null @@ -1,437 +0,0 @@ -# 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. From e178dbff5986fffce863d3b03ab66869b5d895b5 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 11:34:41 -0300 Subject: [PATCH 02/10] fix(app): preserve hydrated dialogs and cached panel rendering --- .../regression/session-rollback-queue.spec.ts | 4 +++ .../app/e2e/smoke/session-timeline.spec.ts | 17 +--------- .../user-story/model-selection-flow.spec.ts | 21 ++++++++++-- .../components/dialog-connect-provider.tsx | 4 ++- packages/app/src/components/file-tree-v2.tsx | 5 +-- .../components/virtual-scroll-element.test.ts | 32 ++++++++++++++++++- .../src/components/virtual-scroll-element.ts | 26 +++++++++++++++ .../pages/session/v2/session-file-list-v2.tsx | 5 +-- .../src/components/message-part.tsx | 4 +-- 9 files changed, 91 insertions(+), 27 deletions(-) diff --git a/packages/app/e2e/regression/session-rollback-queue.spec.ts b/packages/app/e2e/regression/session-rollback-queue.spec.ts index 80880719e8cc..0935d7e586b2 100644 --- a/packages/app/e2e/regression/session-rollback-queue.spec.ts +++ b/packages/app/e2e/regression/session-rollback-queue.spec.ts @@ -181,11 +181,15 @@ test("command undo and redo use the current pending input and replacement draft" await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, session.title) await expect(page.getByText("second user prompt", { exact: true })).toHaveCount(1) + await page.clock.install() 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() + // Dialog portals disappear before the 100ms close timer releases the command lock. + await page.clock.runFor(150) + await expect(page.locator("[data-dialog-layer]")).toHaveCount(0) } const input = page.locator('[data-component="prompt-input"]') await select("Undo") diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index b1410cf4c7e7..f886f907e674 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { currentPageMessages, fixture } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { mockOpenCodeServer } from "../utils/mock-server" -import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAtBottom, scrollToBottom } from "../utils/scroll" const forbiddenText = ["Load details", "Show earlier steps"] @@ -383,9 +383,6 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) - await selectHomeProject(page, fixture.project.name) - await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) - await expectSessionReady(page) await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) const expectedPartIDs = fixture.expected.targetPartIDs const expectedMessageIDs = fixture.expected.targetMessageIDs @@ -763,18 +760,6 @@ function expectCompleteScroll( expect(expectedPartIDs.length).toBe(331) } -async function selectHomeProject(page: Page, projectName: string) { - await page.goto("/") - const row = page - .locator('[data-component="home-project-row"]') - .filter({ hasText: new RegExp(projectName, "i") }) - .first() - await expectAppVisible(row) - await row.click() - await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT }) - await expect(page).toHaveURL(/\/$/) -} - async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) await expectSessionTitle(page, expectedTitle) 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 76581a5587e9..4c1a4b49c9f2 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -5,7 +5,12 @@ import { expectAppVisible } from "../utils/waits" const directory = "C:/OpenCode/NewProject" test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { + const errors: string[] = [] + page.on("pageerror", (error) => errors.push(error.message)) let connectedGo = false + let delayCatalogue = false + const catalogue = Promise.withResolvers() + const requested = Promise.withResolvers() const connections: Array<{ integrationID: string; body: unknown }> = [] await mockOpenCodeServer(page, { @@ -61,8 +66,12 @@ test("creates a session in a new project, connects OpenCode Go, and selects its }) await page.route( (url) => url.pathname === "/api/integration", - (route) => - route.fulfill({ + async (route) => { + if (delayCatalogue) { + requested.resolve() + await catalogue.promise + } + return route.fulfill({ contentType: "application/json", body: JSON.stringify({ location: { directory }, @@ -71,7 +80,8 @@ test("creates a session in a new project, connects OpenCode Go, and selects its { id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }, ], }), - }), + }) + }, ) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) @@ -91,7 +101,11 @@ test("creates a session in a new project, connects OpenCode Go, and selects its await modelControl.click() await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") + delayCatalogue = true await page.locator('[data-provider-id="opencode-go"]').click() + await requested.promise + expect(errors).toEqual([]) + catalogue.resolve() 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) @@ -104,4 +118,5 @@ test("creates a session in a new project, connects OpenCode Go, and selects its await goModel.click() await expect(modelControl).toContainText("Go Model 1") + expect(errors).toEqual([]) }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 1081310e5f21..5a7c1d2bcd8f 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -408,7 +408,9 @@ function ProviderConnection(props: { }) const provider = createMemo( - () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, + () => + providers.all().get(props.provider) ?? + serverSync().data.provider.all.get(props.provider) ?? { id: props.provider, name: props.provider }, ) const fallback = createMemo(() => [ { diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index 15218a7cd14a..0983cf93ba41 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -23,7 +23,7 @@ import { normalizeFileTreeV2Path, type FileTreeV2Node, } from "@/components/file-tree-v2-model" -import { virtualScrollElement } from "@/components/virtual-scroll-element" +import { createVirtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" @@ -142,12 +142,13 @@ export default function FileTreeV2(props: { return flattenFileTreeV2(model()!, expanded) }) const [root, setRoot] = createSignal() + const scrollElement = createVirtualScrollElement(root) const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ get count() { return rows().length }, - getScrollElement: () => virtualScrollElement(root()), + getScrollElement: scrollElement, initialRect: { width: 0, height: 600 }, estimateSize: () => 28, gap: 2, diff --git a/packages/app/src/components/virtual-scroll-element.test.ts b/packages/app/src/components/virtual-scroll-element.test.ts index 20c25a8561a0..984831b4e746 100644 --- a/packages/app/src/components/virtual-scroll-element.test.ts +++ b/packages/app/src/components/virtual-scroll-element.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { virtualScrollElement } from "./virtual-scroll-element" +import { observeVirtualScrollElement, virtualScrollElement } from "./virtual-scroll-element" test("resolves the connected viewport that owns the virtual root", () => { const stale = document.createElement("div") @@ -16,3 +16,33 @@ test("resolves the connected viewport that owns the virtual root", () => { viewport.remove() expect(virtualScrollElement(root)).toBeNull() }) + +test("tracks late mounting, cached-panel reparenting, and disconnection", async () => { + const first = document.createElement("div") + const second = document.createElement("div") + first.className = second.className = "scroll-view__viewport" + const root = document.createElement("div") + first.append(root) + const updates: Array = [] + const dispose = observeVirtualScrollElement(root, (element) => updates.push(element)) + try { + expect(updates).toEqual([null]) + document.body.append(first, second) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(updates.at(-1)).toBe(first) + second.append(root) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(updates.at(-1)).toBe(second) + root.remove() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(updates).toEqual([null, first, second, null]) + dispose() + first.append(root) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(updates).toHaveLength(4) + } finally { + dispose() + first.remove() + second.remove() + } +}) diff --git a/packages/app/src/components/virtual-scroll-element.ts b/packages/app/src/components/virtual-scroll-element.ts index 8708781d86a7..677de1886373 100644 --- a/packages/app/src/components/virtual-scroll-element.ts +++ b/packages/app/src/components/virtual-scroll-element.ts @@ -1,4 +1,30 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + export function virtualScrollElement(root: HTMLElement | undefined) { if (!root?.isConnected) return null return root.closest(".scroll-view__viewport") } + +export function observeVirtualScrollElement(root: HTMLElement, update: (element: HTMLDivElement | null) => void) { + let current = virtualScrollElement(root) + update(current) + // Cached panels can mount or move after Solid's onMount without changing their props. + const observer = new MutationObserver(() => { + const next = virtualScrollElement(root) + if (next === current) return + current = next + update(next) + }) + observer.observe(root.ownerDocument, { childList: true, subtree: true }) + return () => observer.disconnect() +} + +export function createVirtualScrollElement(root: Accessor) { + const [element, setElement] = createSignal(null) + createEffect(() => { + const current = root() + setElement(null) + if (current) onCleanup(observeVirtualScrollElement(current, setElement)) + }) + return element +} diff --git a/packages/app/src/pages/session/v2/session-file-list-v2.tsx b/packages/app/src/pages/session/v2/session-file-list-v2.tsx index 16cac2a45d66..472470072d54 100644 --- a/packages/app/src/pages/session/v2/session-file-list-v2.tsx +++ b/packages/app/src/pages/session/v2/session-file-list-v2.tsx @@ -5,7 +5,7 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2" import { normalizePath } from "@/pages/session/v2/review-diff-kinds" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { virtualScrollElement } from "@/components/virtual-scroll-element" +import { createVirtualScrollElement } from "@/components/virtual-scroll-element" // Drives the highlight/selection of the flat search-result list from the filter // input's keyboard events. @@ -53,12 +53,13 @@ export function SessionFileListV2(props: { const highlighted = () => normalizePath(props.highlighted ?? "") const normalized = createMemo(() => props.files.map(normalizePath)) const [root, setRoot] = createSignal() + const scrollElement = createVirtualScrollElement(root) const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ get count() { return props.files.length }, - getScrollElement: () => virtualScrollElement(root()), + getScrollElement: scrollElement, initialRect: { width: 0, height: 600 }, estimateSize: () => 28, gap: 2, diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 2e7a6b95f26c..8c100f327abe 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -423,7 +423,7 @@ function CurrentHighlightedText(props: { text: string; references: CurrentUserFi if (start < props.text.length) result.push({ text: props.text.slice(start) }) return result }) - return {(segment) => {segment.text}} + return {(segment) => {segment().text}} } export interface MessagePartProps { @@ -1672,7 +1672,7 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents: return result }) - return {(segment) => {segment.text}} + return {(segment) => {segment().text}} } export function Part(props: MessagePartProps) { From 0f7ea20a1f6fd5e40972d0552ee87097c8f95c91 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 11:34:51 -0300 Subject: [PATCH 03/10] fix(ci): install verified ripgrep release on Windows --- .github/workflows/test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f723fddc9464..c935fc964b1c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,15 @@ jobs: - name: Install ripgrep if: runner.os == 'Windows' - run: choco install ripgrep --version 15.1.0 --yes --no-progress + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP "ripgrep.zip" + Invoke-WebRequest "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip" -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne "124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a") { + throw "Ripgrep checksum mismatch" + } + Expand-Archive $archive -DestinationPath "$env:RUNNER_TEMP/ripgrep" + "$env:RUNNER_TEMP/ripgrep/ripgrep-15.1.0-x86_64-pc-windows-msvc" >> $env:GITHUB_PATH - name: Cache Turbo uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 From f5973762e37a8cc802b86d6781be42d44b54d622 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 11:55:57 -0300 Subject: [PATCH 04/10] fix(app): isolate catalogue hydration and preserve admitted model state --- .../regression/session-context-usage.spec.ts | 163 +++++++++--------- .../user-story/model-selection-flow.spec.ts | 2 +- .../src/components/session-context-usage.tsx | 12 +- packages/app/src/hooks/use-providers.ts | 3 +- .../app/src/utils/session-message.test.ts | 27 +++ packages/app/src/utils/session-message.ts | 27 ++- 6 files changed, 140 insertions(+), 94 deletions(-) diff --git a/packages/app/e2e/regression/session-context-usage.spec.ts b/packages/app/e2e/regression/session-context-usage.spec.ts index 0461fc378597..29fdfc227209 100644 --- a/packages/app/e2e/regression/session-context-usage.spec.ts +++ b/packages/app/e2e/regression/session-context-usage.spec.ts @@ -33,89 +33,96 @@ const messages = [0, 1].flatMap((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: [ +for (const newLayoutDesigns of [false, true]) { + for (const mode of ["recorded", "rollback", "live"] as const) { + test(`context circle and detail agree with ${mode} usage (${newLayoutDesigns ? "v2" : "legacy"})`, async ({ + page, + }) => { + await page.addInitScript((newLayoutDesigns) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns } })) + }, newLayoutDesigns) + 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: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 100_000 } } }, + id: sessionID, + directory, + title: "Context usage regression", + cost: 2.5, + time: { created: 1, updated: 5 }, + ...(reverted ? { revert: { messageID: "msg_3_user", inclusive: true } } : {}), }, ], - 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, + 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)) + }), ) - .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 } }, }), ) - .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 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}%`) - }) + await expect(page.getByText("Usage", { exact: true }).last().locator("..")).toContainText(`${percentage}%`) + }) + } } 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 4c1a4b49c9f2..10a78242d455 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -105,8 +105,8 @@ test("creates a session in a new project, connects OpenCode Go, and selects its await page.locator('[data-provider-id="opencode-go"]').click() await requested.promise expect(errors).toEqual([]) - catalogue.resolve() await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") + catalogue.resolve() 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: { key: "mock-go-api-key" } }]) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 4f970032b862..66cf2eec0f35 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js" +import { Match, Show, Switch, batch, createMemo, type ComponentProps, type JSX } from "solid-js" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Button } from "@opencode-ai/ui/button" @@ -41,10 +41,12 @@ function openSessionContext(args: { layout: ReturnType tabs: ReturnType["tabs"]> }) { - args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") - if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") - void args.tabs.open("context") - args.tabs.setActive("context") + batch(() => { + args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") + if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") + void args.tabs.open("context") + args.tabs.setActive("context") + }) } export function SessionContextUsage(props: SessionContextUsageProps) { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 36c0407923d6..b87af5c27e44 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -50,7 +50,8 @@ export function useProviders(directory: Accessor) { } const all = createMemo(() => { - const value = choices.latest + // Optional connection choices must not suspend the session while a picker mounts. + const value = choices.state === "ready" || choices.state === "refreshing" ? choices.latest : undefined return mergeProviderChoices( providers().all, value?.server === serverSDK() && value.directory === dir() ? value.items : [], diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index 1e4aea53e18b..38c0e54b416d 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -96,6 +96,32 @@ describe("normalizeSessionMessages", () => { ]) }) + test("keeps admitted model metadata authoritative across a provider switch", () => { + const payload = { + version: 1, + agent: "plan", + model: { providerID: "anthropic", modelID: "sonnet", variant: "high" }, + parts: [{ type: "text", text: "Admitted prompt" }], + } + const result = normalizeCurrentSessionMessages("ses_1", [ + decodeCurrentMessage({ id: "user", type: "user", text: "Admitted prompt", payload, time: { created: 1 } }), + decodeCurrentMessage({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "openai", id: "gpt" }, + content: [], + time: { created: 2, completed: 3 }, + }), + decodeCurrentMessage({ id: "pending", type: "user", text: "Next prompt", payload, time: { created: 4 } }), + ]) + expect(result.messages).toMatchObject([ + { id: "user", agent: payload.agent, model: payload.model }, + { id: "assistant", agent: "build", providerID: "openai", modelID: "gpt" }, + { id: "pending", agent: payload.agent, model: payload.model }, + ]) + }) + test("preserves current user attachments and payload comments in the compatibility projection", () => { const text = "Use @explore with @src/a.ts" const parts = [ @@ -138,6 +164,7 @@ describe("normalizeSessionMessages", () => { payload: { version: 1, agent: "build", model: { providerID: "provider", modelID: "model" }, parts }, }), ]) + expect(result.messages[0]).toMatchObject({ agent: "build", model: { providerID: "provider", modelID: "model" } }) expect(result.parts.get("msg_rich")).toMatchObject(parts) expect( result.parts.get("msg_rich")?.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_rich"), diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index c3b1cd8905af..ad58e57f96d3 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -25,6 +25,8 @@ type PreparedAssistant = SessionMessageAssistant & { snapshot?: { diffs?: SnapshotFileDiff[] } } +type PreparedUser = SessionMessageUser & { payload?: Extract["payload"] } + const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) @@ -145,6 +147,7 @@ export function normalizeSessionMessages(sessionID: string, source: readonly Ses let agent = "" let model = emptyModel let parentID: string | undefined + let parentHasPayload = false source.forEach((message) => { if (message.type === "agent-switched") { @@ -156,12 +159,19 @@ export function normalizeSessionMessages(sessionID: string, source: readonly Ses return } if (message.type === "user") { + const payload = (message as PreparedUser).payload + parentHasPayload = !!payload + if (payload) { + agent = payload.agent + model = { providerID: payload.model.providerID, id: payload.model.modelID, variant: payload.model.variant } + } parentID = message.id messages.push(userMessage(sessionID, message, agent, model)) parts.set(message.id, userParts(sessionID, message)) return } if (message.type === "synthetic" && message.description?.trim()) { + parentHasPayload = false parentID = message.id messages.push({ id: message.id, @@ -187,11 +197,13 @@ export function normalizeSessionMessages(sessionID: string, source: readonly Ses if (!parentID) return const parent = messages.findLast((item) => item.id === parentID) if (parent?.role === "user") { - parent.agent = message.agent - parent.model = { - providerID: message.model.providerID, - modelID: message.model.id, - variant: message.model.variant, + if (!parentHasPayload) { + parent.agent = message.agent + parent.model = { + providerID: message.model.providerID, + modelID: message.model.id, + variant: message.model.variant, + } } const diffs = (message as PreparedAssistant).snapshot?.diffs if (diffs) parent.summary = { diffs } @@ -298,10 +310,7 @@ function userMessage( } } -function userParts( - sessionID: string, - message: SessionMessageUser & { payload?: Extract["payload"] }, -): Part[] { +function userParts(sessionID: string, message: PreparedUser): Part[] { if (message.payload) { const ordinals = { text: 0, file: 0, agent: 0, subtask: 0 } return message.payload.parts.map((part) => ({ From 0aba3ce6a5766fd27716edb7cdfd195ffc04fb20 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 11:56:41 -0300 Subject: [PATCH 05/10] test(app): use schema-valid IDs in model projection regression --- packages/app/src/utils/session-message.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index 38c0e54b416d..e50a6de6f6ad 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -104,21 +104,21 @@ describe("normalizeSessionMessages", () => { parts: [{ type: "text", text: "Admitted prompt" }], } const result = normalizeCurrentSessionMessages("ses_1", [ - decodeCurrentMessage({ id: "user", type: "user", text: "Admitted prompt", payload, time: { created: 1 } }), + decodeCurrentMessage({ id: "msg_user", type: "user", text: "Admitted prompt", payload, time: { created: 1 } }), decodeCurrentMessage({ - id: "assistant", + id: "msg_assistant", type: "assistant", agent: "build", model: { providerID: "openai", id: "gpt" }, content: [], time: { created: 2, completed: 3 }, }), - decodeCurrentMessage({ id: "pending", type: "user", text: "Next prompt", payload, time: { created: 4 } }), + decodeCurrentMessage({ id: "msg_pending", type: "user", text: "Next prompt", payload, time: { created: 4 } }), ]) expect(result.messages).toMatchObject([ - { id: "user", agent: payload.agent, model: payload.model }, - { id: "assistant", agent: "build", providerID: "openai", modelID: "gpt" }, - { id: "pending", agent: payload.agent, model: payload.model }, + { id: "msg_user", agent: payload.agent, model: payload.model }, + { id: "msg_assistant", agent: "build", providerID: "openai", modelID: "gpt" }, + { id: "msg_pending", agent: payload.agent, model: payload.model }, ]) }) From 85c1f6722cd298960b3064c7c21ad7f6674068c0 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 12:30:30 -0300 Subject: [PATCH 06/10] fix(ci): expose test progress and budget Windows runner time --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c935fc964b1c..c1462f22c030 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,8 +77,8 @@ jobs: turbo-${{ runner.os }}- - name: Run unit tests - timeout-minutes: 20 - run: GITHUB_ACTIONS=false bun turbo test --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || '2' }} + timeout-minutes: ${{ github.repository != 'anomalyco/opencode' && runner.os == 'Windows' && 60 || 20 }} + run: GITHUB_ACTIONS=false bun turbo test --log-order=stream --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || '2' }} env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} From 7f4687cb760e1f1a462f959978ee893694c62c1b Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 13:29:51 -0300 Subject: [PATCH 07/10] test(core): budget native shell and Git integration startup --- packages/core/test/session-prompt.test.ts | 53 +++--- packages/core/test/snapshot.test.ts | 188 +++++++++++----------- 2 files changed, 125 insertions(+), 116 deletions(-) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 976dc609f8e9..557d7774598f 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -177,31 +177,34 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("records shell commands and output in current Session history", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - yield* db - .update(SessionTable) - .set({ directory: process.cwd() }) - .where(eq(SessionTable.id, sessionID)) - .run() - .pipe(Effect.orDie) - const session = yield* SessionV2.Service - - 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, - output: "current-shell-output", - }, - ]) - }), + it.effect( + "records shell commands and output in current Session history", + () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ directory: process.cwd() }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + const session = yield* SessionV2.Service + + 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, + output: "current-shell-output", + }, + ]) + }), + 30_000, ) it.effect("pending UI inputs exclude queued, discarded and promoted messages", () => diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 5e01fefc17dc..9ed3637392a1 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -13,58 +13,61 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" describe("Snapshot", () => { - testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => - Effect.gen(function* () { - const project = path.join(tmp.path, "project") - const location = path.join(project, "scope") - yield* Effect.promise(async () => { - await fs.mkdir(location, { recursive: true }) - await fs.writeFile(path.join(location, "tracked.txt"), "one\n") - await fs.writeFile(path.join(project, "outside.txt"), "outside\n") - await $`git init`.cwd(project).quiet() - await $`git config core.fsmonitor false`.cwd(project).quiet() - await $`git config commit.gpgsign false`.cwd(project).quiet() - await $`git config user.email test@opencode.test`.cwd(project).quiet() - await $`git config user.name Test`.cwd(project).quiet() - await $`git add .`.cwd(project).quiet() - await $`git commit -m initial`.cwd(project).quiet() - }) - - const layer = snapshotLayer(tmp.path, location) - yield* Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - const before = yield* snapshot.capture() - expect(before).toBeDefined() - if (!before) return - + testEffect(Layer.empty).live( + "captures and restores Location-scoped changes", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const location = path.join(project, "scope") yield* Effect.promise(async () => { - await fs.writeFile(path.join(location, "tracked.txt"), "two\n") - await fs.writeFile(path.join(location, "added.txt"), "added\n") - await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n") + await fs.mkdir(location, { recursive: true }) + await fs.writeFile(path.join(location, "tracked.txt"), "one\n") + await fs.writeFile(path.join(project, "outside.txt"), "outside\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() }) - const after = yield* snapshot.capture() - expect(after).toBeDefined() - if (!after) return - expect(yield* snapshot.files({ from: before, to: after })).toEqual([ - RelativePath.make("scope/added.txt"), - RelativePath.make("scope/tracked.txt"), - ]) - const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) - const preview = yield* snapshot.preview({ files: plan, context: 1 }) - expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) - yield* snapshot.restore({ files: plan }) - expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") - expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") - expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n") - }).pipe(Effect.provide(layer)) - }), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + const layer = snapshotLayer(tmp.path, location) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + expect(before).toBeDefined() + if (!before) return + + yield* Effect.promise(async () => { + await fs.writeFile(path.join(location, "tracked.txt"), "two\n") + await fs.writeFile(path.join(location, "added.txt"), "added\n") + await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n") + }) + const after = yield* snapshot.capture() + expect(after).toBeDefined() + if (!after) return + + expect(yield* snapshot.files({ from: before, to: after })).toEqual([ + RelativePath.make("scope/added.txt"), + RelativePath.make("scope/tracked.txt"), + ]) + const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) + const preview = yield* snapshot.preview({ files: plan, context: 1 }) + expect(preview).toHaveLength(1) + expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + yield* snapshot.restore({ files: plan }) + expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") + expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n") + }).pipe(Effect.provide(layer)) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + 30_000, ) testEffect(Layer.empty).live("treats capture outside Git as unavailable", () => @@ -83,50 +86,53 @@ describe("Snapshot", () => { ), ) - testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => - Effect.gen(function* () { - const project = path.join(tmp.path, "project") - const linked = path.join(tmp.path, "linked") - yield* Effect.promise(async () => { - await fs.mkdir(project) - await fs.writeFile(path.join(project, "tracked.txt"), "main\n") - await $`git init`.cwd(project).quiet() - await $`git config core.fsmonitor false`.cwd(project).quiet() - await $`git config commit.gpgsign false`.cwd(project).quiet() - await $`git config user.email test@opencode.test`.cwd(project).quiet() - await $`git config user.name Test`.cwd(project).quiet() - await $`git add .`.cwd(project).quiet() - await $`git commit -m initial`.cwd(project).quiet() - await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet() - }) + testEffect(Layer.empty).live( + "isolates snapshot indexes by canonical Git worktree", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const linked = path.join(tmp.path, "linked") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "main\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet() + }) - const capture = (directory: string) => - Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - return yield* snapshot.capture() - }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) - expect(yield* capture(project)).toBeDefined() - expect(yield* capture(linked)).toBeDefined() + const capture = (directory: string) => + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) + expect(yield* capture(project)).toBeDefined() + expect(yield* capture(linked)).toBeDefined() - const projectID = yield* Effect.gen(function* () { - return (yield* Location.Service).project.id - }).pipe( - Effect.provide( - AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), - ), - ) - expect( - yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), - ).toBeDefined() - expect( - yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), - ).toBeDefined() - }), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + const projectID = yield* Effect.gen(function* () { + return (yield* Location.Service).project.id + }).pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), + ).toBeDefined() + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), + ).toBeDefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + 30_000, ) testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () => From 758e264d83d6b8578a344c1c4ca7628b5feabec4 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 14:11:11 -0300 Subject: [PATCH 08/10] test(opencode): make native smoke harnesses portable --- .../opencode/test/cli/acp/lifecycle.test.ts | 3 ++- .../cli/tui/prompt-queue-tui-smoke.cli.test.ts | 4 +++- .../test/tool/external-directory.test.ts | 5 +---- packages/opencode/test/tool/read.test.ts | 5 +---- .../cli/tui/scripts/prompt-queue-tui-smoke.sh | 17 ++++++++++------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/opencode/test/cli/acp/lifecycle.test.ts b/packages/opencode/test/cli/acp/lifecycle.test.ts index 9f2558ea2f58..58c7541b9420 100644 --- a/packages/opencode/test/cli/acp/lifecycle.test.ts +++ b/packages/opencode/test/cli/acp/lifecycle.test.ts @@ -18,7 +18,8 @@ describe("opencode acp lifecycle subprocess", () => { const acp = yield* opencode.acp() acp.close() - const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5))) + // EOF is sent before startup, so this includes cold process/module loading. + const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(30))) expect(code).toBe(0) }), 60_000, diff --git a/packages/opencode/test/cli/tui/prompt-queue-tui-smoke.cli.test.ts b/packages/opencode/test/cli/tui/prompt-queue-tui-smoke.cli.test.ts index ef072fa9e83a..7e713f7c2b6f 100644 --- a/packages/opencode/test/cli/tui/prompt-queue-tui-smoke.cli.test.ts +++ b/packages/opencode/test/cli/tui/prompt-queue-tui-smoke.cli.test.ts @@ -34,7 +34,7 @@ const deferredAsPromise = (deferred: Deferred.Deferred): PromiseLike => }, }) -describe("prompt queue TUI smoke (serve + script)", () => { +describe.each([false, true])("prompt queue TUI smoke (skipAttach=%s)", (skipAttach) => { cliIt.live( "queues three deferred prompts in fifo via serve API and attach screencap", ({ llm, home, opencode }) => @@ -132,6 +132,7 @@ describe("prompt queue TUI smoke (serve + script)", () => { OPENCODE_ARTIFACT_DIR: artifactDir, OPENCODE_QUEUE_ONLY: "1", OPENCODE_EDIT_FIRST: "1", + OPENCODE_SKIP_ATTACH: skipAttach ? "1" : "0", OPENCODE_CLI_ENTRY: path.join(opencodeRoot, "src/index.ts"), }, stdout: "pipe", @@ -200,6 +201,7 @@ describe("prompt queue TUI smoke (serve + script)", () => { readFile(path.join(artifactDir, "tui-attach.txt"), "utf8").catch(() => "attach skipped"), ) expect(attach.length).toBeGreaterThan(0) + if (skipAttach) expect(attach).toContain("queue edit exercised through API, not terminal") }), 120_000, ) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index d43accfb70dc..d50060fe300b 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -116,10 +116,7 @@ describe("tool.assertExternalDirectory", () => { yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) const target = path.join(outerTmp, "outside.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + const alt = target.replaceAll("\\", "/").toLowerCase() yield* assertExternalDirectoryEffect(ctx, alt) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c1ef61b227dd..89f482b8d79b 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -192,10 +192,7 @@ describe("tool.read external_directory permission", () => { const { items, next } = asks() const target = path.join(dir, "test.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + const alt = target.replaceAll("\\", "/").toLowerCase() yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") diff --git a/packages/tui/test/cli/tui/scripts/prompt-queue-tui-smoke.sh b/packages/tui/test/cli/tui/scripts/prompt-queue-tui-smoke.sh index 036c9deae103..17d057bd7a58 100755 --- a/packages/tui/test/cli/tui/scripts/prompt-queue-tui-smoke.sh +++ b/packages/tui/test/cli/tui/scripts/prompt-queue-tui-smoke.sh @@ -10,7 +10,7 @@ # OPENCODE_ARTIFACT_DIR — writes session-id.txt, queued.txt, tui-attach.txt # OPENCODE_QUEUE_ONLY=1 — only queue deferred messages (open turn started elsewhere) # OPENCODE_EDIT_FIRST=1 — edit the first queued message and save it with Return -# OPENCODE_SKIP_ATTACH=1 — skip `script` capture of `opencode attach` +# OPENCODE_SKIP_ATTACH=1 — skip terminal capture; edit through the API instead # OPENCODE_CLI_ENTRY — path to src/index.ts (defaults below) # set -euo pipefail @@ -78,12 +78,15 @@ attach_capture() { local sid=$1 local out="${ARTIFACT_DIR}/tui-attach.txt" : >"$out" - if [[ "${OPENCODE_SKIP_ATTACH:-}" == "1" ]]; then - printf 'attach skipped\n' >"$out" - return 0 - fi - if ! command -v script >/dev/null 2>&1; then - printf 'attach skipped: script(1) not found\n' >"$out" + if [[ "${OPENCODE_SKIP_ATTACH:-}" == "1" ]] || ! command -v script >/dev/null 2>&1; then + printf 'attach skipped: disabled or script(1) unavailable\n' >"$out" + if [[ "${OPENCODE_EDIT_FIRST:-}" == "1" ]]; then + local queued + queued="$(api GET "/api/session/${sid}/queue" | jq -ec '.data[0]')" + api PATCH "/api/session/${sid}/queue/$(jq -er '.id' <<<"$queued")" \ + -d "$(jq -c '{payload: (.payload | .parts[0].text += "-edited")}' <<<"$queued")" >/dev/null + printf 'queue edit exercised through API, not terminal\n' >>"$out" + fi return 0 fi if [[ "${OPENCODE_EDIT_FIRST:-}" == "1" ]]; then From ad00df82a1cdd4e617445ce34dc4c55ee375af27 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 14:45:38 -0300 Subject: [PATCH 09/10] test(core): align integration timeout with opencode suite --- packages/core/package.json | 2 +- packages/core/test/session-prompt.test.ts | 53 +++--- packages/core/test/snapshot.test.ts | 188 +++++++++++----------- 3 files changed, 117 insertions(+), 126 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index f411779db980..c50effce2b19 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test --only-failures", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "bin": { diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 557d7774598f..976dc609f8e9 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -177,34 +177,31 @@ describe("SessionV2.prompt", () => { }), ) - it.effect( - "records shell commands and output in current Session history", - () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - yield* db - .update(SessionTable) - .set({ directory: process.cwd() }) - .where(eq(SessionTable.id, sessionID)) - .run() - .pipe(Effect.orDie) - const session = yield* SessionV2.Service - - 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, - output: "current-shell-output", - }, - ]) - }), - 30_000, + it.effect("records shell commands and output in current Session history", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ directory: process.cwd() }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + const session = yield* SessionV2.Service + + 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, + output: "current-shell-output", + }, + ]) + }), ) it.effect("pending UI inputs exclude queued, discarded and promoted messages", () => diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 9ed3637392a1..5e01fefc17dc 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -13,61 +13,58 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" describe("Snapshot", () => { - testEffect(Layer.empty).live( - "captures and restores Location-scoped changes", - () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => - Effect.gen(function* () { - const project = path.join(tmp.path, "project") - const location = path.join(project, "scope") - yield* Effect.promise(async () => { - await fs.mkdir(location, { recursive: true }) - await fs.writeFile(path.join(location, "tracked.txt"), "one\n") - await fs.writeFile(path.join(project, "outside.txt"), "outside\n") - await $`git init`.cwd(project).quiet() - await $`git config core.fsmonitor false`.cwd(project).quiet() - await $`git config commit.gpgsign false`.cwd(project).quiet() - await $`git config user.email test@opencode.test`.cwd(project).quiet() - await $`git config user.name Test`.cwd(project).quiet() - await $`git add .`.cwd(project).quiet() - await $`git commit -m initial`.cwd(project).quiet() - }) + testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const location = path.join(project, "scope") + yield* Effect.promise(async () => { + await fs.mkdir(location, { recursive: true }) + await fs.writeFile(path.join(location, "tracked.txt"), "one\n") + await fs.writeFile(path.join(project, "outside.txt"), "outside\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) - const layer = snapshotLayer(tmp.path, location) - yield* Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - const before = yield* snapshot.capture() - expect(before).toBeDefined() - if (!before) return + const layer = snapshotLayer(tmp.path, location) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + expect(before).toBeDefined() + if (!before) return - yield* Effect.promise(async () => { - await fs.writeFile(path.join(location, "tracked.txt"), "two\n") - await fs.writeFile(path.join(location, "added.txt"), "added\n") - await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n") - }) - const after = yield* snapshot.capture() - expect(after).toBeDefined() - if (!after) return + yield* Effect.promise(async () => { + await fs.writeFile(path.join(location, "tracked.txt"), "two\n") + await fs.writeFile(path.join(location, "added.txt"), "added\n") + await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n") + }) + const after = yield* snapshot.capture() + expect(after).toBeDefined() + if (!after) return - expect(yield* snapshot.files({ from: before, to: after })).toEqual([ - RelativePath.make("scope/added.txt"), - RelativePath.make("scope/tracked.txt"), - ]) - const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) - const preview = yield* snapshot.preview({ files: plan, context: 1 }) - expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) - yield* snapshot.restore({ files: plan }) - expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") - expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") - expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n") - }).pipe(Effect.provide(layer)) - }), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - 30_000, + expect(yield* snapshot.files({ from: before, to: after })).toEqual([ + RelativePath.make("scope/added.txt"), + RelativePath.make("scope/tracked.txt"), + ]) + const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) + const preview = yield* snapshot.preview({ files: plan, context: 1 }) + expect(preview).toHaveLength(1) + expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + yield* snapshot.restore({ files: plan }) + expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") + expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n") + }).pipe(Effect.provide(layer)) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), ) testEffect(Layer.empty).live("treats capture outside Git as unavailable", () => @@ -86,53 +83,50 @@ describe("Snapshot", () => { ), ) - testEffect(Layer.empty).live( - "isolates snapshot indexes by canonical Git worktree", - () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => - Effect.gen(function* () { - const project = path.join(tmp.path, "project") - const linked = path.join(tmp.path, "linked") - yield* Effect.promise(async () => { - await fs.mkdir(project) - await fs.writeFile(path.join(project, "tracked.txt"), "main\n") - await $`git init`.cwd(project).quiet() - await $`git config core.fsmonitor false`.cwd(project).quiet() - await $`git config commit.gpgsign false`.cwd(project).quiet() - await $`git config user.email test@opencode.test`.cwd(project).quiet() - await $`git config user.name Test`.cwd(project).quiet() - await $`git add .`.cwd(project).quiet() - await $`git commit -m initial`.cwd(project).quiet() - await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet() - }) + testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const linked = path.join(tmp.path, "linked") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "main\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet() + }) - const capture = (directory: string) => - Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - return yield* snapshot.capture() - }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) - expect(yield* capture(project)).toBeDefined() - expect(yield* capture(linked)).toBeDefined() + const capture = (directory: string) => + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) + expect(yield* capture(project)).toBeDefined() + expect(yield* capture(linked)).toBeDefined() - const projectID = yield* Effect.gen(function* () { - return (yield* Location.Service).project.id - }).pipe( - Effect.provide( - AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), - ), - ) - expect( - yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), - ).toBeDefined() - expect( - yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), - ).toBeDefined() - }), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - 30_000, + const projectID = yield* Effect.gen(function* () { + return (yield* Location.Service).project.id + }).pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), + ).toBeDefined() + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), + ).toBeDefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), ) testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () => From 05da27eebf2f498079ad458bc02b0a54ec5c98d1 Mon Sep 17 00:00:00 2001 From: henry701 Date: Sat, 5 Sep 2026 16:28:36 -0300 Subject: [PATCH 10/10] test(ci): budget Windows native initialization without contention --- .github/workflows/test.yml | 2 +- packages/app/src/i18n/desktop-native.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1462f22c030..910c1213751e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: - name: Run unit tests timeout-minutes: ${{ github.repository != 'anomalyco/opencode' && runner.os == 'Windows' && 60 || 20 }} - run: GITHUB_ACTIONS=false bun turbo test --log-order=stream --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || '2' }} + run: GITHUB_ACTIONS=false bun turbo test --log-order=stream --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || (runner.os == 'Windows' && '1' || '2') }} env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} diff --git a/packages/app/src/i18n/desktop-native.test.ts b/packages/app/src/i18n/desktop-native.test.ts index 6fc8660cced5..17a81817a7ee 100644 --- a/packages/app/src/i18n/desktop-native.test.ts +++ b/packages/app/src/i18n/desktop-native.test.ts @@ -147,5 +147,5 @@ describe("desktop native ICU data", () => { expect(() => new Intl.DisplayNames(tag, { type: "language" }), `${locale} names`).not.toThrow() expect(() => new Intl.Segmenter(tag), `${locale} segmenter`).not.toThrow() } - }) + }, 30_000) })