From 0ee7be89aa91a55c7c6e5b643bf76b8caa1d7e07 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 20 Sep 2026 01:37:57 -0700 Subject: [PATCH 1/3] test(remote): cover the phone projection on a REAL v3 workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1031 item 4. Every phone-projection test built a v2 document by hand — `tabs[]` with a `root` tile tree — so nothing in the suite exercised the shape the app has actually been persisting since #1013 migrated to the unified stage. The projection DOES handle v3, but a regression in that half would have been silent, and the phone's entire session list depends on it. The proof that this was a real blind spot rather than a tidiness exercise: deleting the v3 project parsing from `agentActivity/workspaceProjection.ts` leaves the existing phone suite completely GREEN (4 passed) and fails only the new file. Ignoring the row's own `projectId` — the v3 membership rule — does the same. The input is the real `~/.config/agent-code/workspace.json` the app wrote on 2026-09-20: one window, three projects, 13 sessions across five provider kinds. Sanitized under the same contract as the v2 fixture — structure and every value unchanged except `cwd`, `title` and `projectTabTitle`, each mapped one-to-one so equal values stay equal — and verified afterwards to contain no absolute home path, username or real directory name. Each test guards against being vacuous: the first asserts the fixture really is v3 with no `tabs` to fall back on, the membership test asserts the file actually places sessions in more than one project, and the identity test asserts four or more provider kinds are represented. The v2 test file is deliberately left alone rather than parameterised: main reads whatever is on disk, and a window that has not saved since the upgrade still holds a v2 document. Both generations must keep working, so both keep their own evidence. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/remote/workspaceProjectionV3.test.ts | 155 ++++++++++ .../2026-09-20-live-workspace.sanitized.json | 267 ++++++++++++++++++ testing/fixtures/workspace-v3/README.md | 35 +++ 3 files changed, 457 insertions(+) create mode 100644 src/main/remote/workspaceProjectionV3.test.ts create mode 100644 testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json create mode 100644 testing/fixtures/workspace-v3/README.md diff --git a/src/main/remote/workspaceProjectionV3.test.ts b/src/main/remote/workspaceProjectionV3.test.ts new file mode 100644 index 000000000..c05d59f7e --- /dev/null +++ b/src/main/remote/workspaceProjectionV3.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +import type { PersistedWindow } from '@main/storage/workspaceFile.js' +import type { WorkspaceFileStore } from '@main/storage/workspaceFileStore.js' +import { RemoteWorkspaceProjection } from './workspaceProjection' + +// --------------------------------------------------------------------------- +// #1031 item 4. Every phone-projection test built a v2 document by hand — +// `tabs[]` with a `root` tile tree — so nothing in the suite exercised the +// shape the app has actually been persisting since #1013 migrated to the +// unified stage. The projection DOES handle v3, but a regression in that half +// would have been silent, and the phone's entire session list depends on it. +// +// The input is the REAL v3 file the app wrote on 2026-09-20, sanitized +// (testing/fixtures/workspace-v3/README.md): one window, three projects, 13 +// sessions across five kinds. +// +// WHY the v2 test file is left alone rather than parameterised: main reads +// whatever is on disk, and a window that has not saved since the upgrade still +// holds a v2 document. Both generations must keep working, so both keep their +// own evidence. +// --------------------------------------------------------------------------- + +const RECORDED = JSON.parse( + readFileSync( + resolve(__dirname, '../../../testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json'), + 'utf8', + ), +) as { version: number; windows: PersistedWindow[] } + +const WINDOW = RECORDED.windows[0]! +const WORKSPACE = (WINDOW as unknown as { + workspace: { + projects: { id: string; title: string }[] + sessions: Record + } +}).workspace + +/** Mirrors the v2 file's store seam: the real store notifies only after bytes + * reach disk, and exposes the current windows synchronously. */ +function fakeStore(saves: PersistedWindow[][]) { + let windows: readonly PersistedWindow[] = saves[0] ?? [] + let cursor = 1 + const observers = new Set<(w: readonly PersistedWindow[]) => void>() + return { + windows: () => windows, + observe(listener: (w: readonly PersistedWindow[]) => void) { + observers.add(listener) + return () => observers.delete(listener) + }, + commitNext() { + windows = saves[cursor] ?? windows + cursor += 1 + for (const observer of observers) observer(windows) + }, + } +} + +function project(saves: PersistedWindow[][]) { + const store = fakeStore(saves) + const projection = new RemoteWorkspaceProjection( + store as unknown as WorkspaceFileStore, + () => Promise.resolve({}), + ) + return { projection, commitNext: () => store.commitNext() } +} + +describe('the phone projection on a REAL v3 workspace (#1031 item 4)', () => { + it('is actually a v3 document, with no v2 tile tree to fall back on', () => { + // Guard the guard: if the fixture ever stops being v3, every assertion + // below would pass for the wrong reason. + expect(RECORDED.version).toBe(3) + expect(WORKSPACE.projects.length).toBeGreaterThan(0) + expect((WORKSPACE as unknown as { tabs?: unknown }).tabs ?? []).toEqual([]) + }) + + it('projects every session in the file', () => { + const { projection } = project([[WINDOW]]) + try { + const snapshot = projection.snapshot() + expect(snapshot.size).toBe(Object.keys(WORKSPACE.sessions).length) + for (const sessionId of Object.keys(WORKSPACE.sessions)) { + expect(snapshot.get(sessionId)).toBeDefined() + } + } finally { + projection.dispose() + } + }) + + it('resolves each session to its project through `projectId`, not a tile tree', () => { + // THE v3 membership rule. A reader that only understands `tabs[].root` + // finds every session but places none of them, so the phone would list + // agents with no project — which is exactly the regression this catches. + const { projection } = project([[WINDOW]]) + try { + const titleById = new Map(WORKSPACE.projects.map(entry => [entry.id, entry.title])) + const placed = Object.entries(WORKSPACE.sessions) + .filter(([, meta]) => meta.projectId && titleById.has(meta.projectId)) + // The recorded file must actually exercise this, or the test is vacuous. + expect(placed.length).toBeGreaterThan(0) + + const snapshot = projection.snapshot() + for (const [sessionId, meta] of placed) { + expect(snapshot.get(sessionId)?.tabTitle).toBe(titleById.get(meta.projectId!)) + } + // More than one project is represented, so a projection that hard-coded + // a single title could not pass. + const titles = new Set(placed.map(([, meta]) => titleById.get(meta.projectId!))) + expect(titles.size).toBeGreaterThan(1) + } finally { + projection.dispose() + } + }) + + it('carries the identity fields the phone renders, for every provider kind', () => { + const { projection } = project([[WINDOW]]) + try { + const snapshot = projection.snapshot() + const kinds = new Set() + for (const [sessionId, meta] of Object.entries(WORKSPACE.sessions)) { + const identity = snapshot.get(sessionId)! + expect(identity.kind).toBe(meta.kind) + expect(identity.cwd).toBe(meta.cwd ?? null) + kinds.add(identity.kind) + } + // claude, opencode, codex, terminal and extension-view all present, so + // a kind-specific break cannot hide. + expect(kinds.size).toBeGreaterThanOrEqual(4) + } finally { + projection.dispose() + } + }) + + it('re-projects a v3 save the same way it re-projects a v2 one', () => { + // The freshness contract, on the shape that is actually being written. + const onChange = vi.fn() + const renamed = JSON.parse(JSON.stringify(WINDOW)) as typeof WINDOW + const sessionId = Object.keys(WORKSPACE.sessions)[0]! + ;(renamed as unknown as { workspace: { sessions: Record } }) + .workspace.sessions[sessionId]!.title = 'Renamed on the stage' + + const { projection, commitNext } = project([[WINDOW], [renamed]]) + try { + projection.onChange(onChange) + onChange.mockClear() + commitNext() + expect(onChange).toHaveBeenCalled() + expect(projection.snapshot().get(sessionId)?.title).toBe('Renamed on the stage') + } finally { + projection.dispose() + } + }) +}) diff --git a/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json b/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json new file mode 100644 index 000000000..e029234c6 --- /dev/null +++ b/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json @@ -0,0 +1,267 @@ +{ + "version": 3, + "windows": [ + { + "windowId": "8ba30627-5c41-4986-a325-b4ada30aa5f0", + "bounds": { + "x": 35, + "y": 33, + "width": 1400, + "height": 859 + }, + "displayId": 1, + "fullScreen": true, + "workspace": { + "projects": [ + { + "id": "3bf27c7f-2e3a-4da1-a35a-e013ad86f937", + "title": "Title 1" + }, + { + "id": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "title": "Title 2" + }, + { + "id": "d3a84a9d-2993-4423-9fa4-8c6a457b4e7b", + "title": "Title 3" + } + ], + "activeProjectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "stage": { + "lanes": [ + { + "selectedSessionId": "5906040e-f07c-4699-a153-d79647e07edb" + }, + { + "selectedSessionId": "edc2bc2f-56cc-4313-bb59-2ea6d84dadcf" + } + ], + "rows": [ + { + "length": 2, + "capChildren": false, + "indexFraction": 0.1, + "height": 0.4928831303208002 + } + ], + "focusedLane": 0, + "laneWeights": [ + 0.22054063618877104, + 0.24008905432342076 + ] + }, + "sessions": { + "575880c6-d447-49b8-aa9b-64705d70c287": { + "tldrIdentity": "6fb1cb83-be8e-4def-a697-0c1568bccdf7", + "cwd": "/fixture/project-1", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "575880c6-d447-49b8-aa9b-64705d70c287", + "providerSessionId": "dc60ca6b-50a9-4179-b36e-12f6de366f49", + "providerSessionIdSource": "proxy-header", + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 0 + }, + "e6e19a29-f8b4-44da-bb9a-38fcfca2a314": { + "tldrIdentity": "8181bc5b-8eb8-4ee5-b2a0-85c8a9f17e39", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_11e052c875fb4a9a85c25fe68823817c", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "agent_transcripts", + "workflows", + "orchestration", + "tldr", + "goal", + "root_management" + ], + "builtInMcpOverrides": { + "root_management": true + }, + "agentNameId": "e6e19a29-f8b4-44da-bb9a-38fcfca2a314", + "projectId": "3bf27c7f-2e3a-4da1-a35a-e013ad86f937", + "joinedAt": 1789450871302 + }, + "9bb36de4-39a0-434a-b0b1-00f017d759bd": { + "tldrIdentity": "c95d99b5-ce11-4567-ae13-a848117e59d0", + "cwd": "/fixture/project-3", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "9bb36de4-39a0-434a-b0b1-00f017d759bd", + "providerSessionId": "2a2aa626-fcb0-4f47-a467-2f928b913a0b", + "providerSessionIdSource": "proxy-header", + "projectId": "d3a84a9d-2993-4423-9fa4-8c6a457b4e7b", + "joinedAt": 0 + }, + "1220cbcb-cabf-41b8-a37c-cebd8b506ce7": { + "tldrIdentity": "4a8a189b-d663-4190-a82c-d44f5893e86d", + "cwd": "/fixture/project-3", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_a121d2d5434b4b6bb970ee5dfc170156", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "agent_transcripts", + "workflows", + "orchestration", + "tldr", + "goal" + ], + "builtInMcpOverrides": {}, + "agentNameId": "1220cbcb-cabf-41b8-a37c-cebd8b506ce7", + "projectId": "d3a84a9d-2993-4423-9fa4-8c6a457b4e7b", + "joinedAt": 1789679456635 + }, + "a15c8aa6-9d69-4907-ad51-71f7fbe5cc01": { + "cwd": "/fixture/project-1", + "kind": "terminal", + "tmuxName": "agentcode-b5f36ea3-4d90-4b32-866e-586bfd09b3c7", + "builtInMcpOverrides": {}, + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789693969704 + }, + "6579b6f4-77eb-4813-93dd-2e66a9c46789": { + "tldrIdentity": "d4b81a48-1657-4038-8b92-e49107ee22c2", + "cwd": "/fixture/project-1", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_6840aca1a7c64d5eaa557982bb32284f", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows" + ], + "builtInMcpOverrides": {}, + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789695773526 + }, + "4d757102-4a13-47c2-9fe0-8a9359cd8323": { + "tldrIdentity": "7c218c21-e550-41b6-805e-3399481313fc", + "cwd": "/fixture/project-1", + "kind": "codex", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows" + ], + "builtInMcpOverrides": {}, + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789763911968 + }, + "5906040e-f07c-4699-a153-d79647e07edb": { + "tldrIdentity": "2776c698-22a4-41ee-bed2-26cd0ac41e20", + "cwd": "/fixture/project-1", + "kind": "claude", + "providerSessionId": "83a02301-2e9a-42a6-98cb-38cffc008e25", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop", + "root_management" + ], + "builtInMcpOverrides": { + "root_management": true + }, + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789782517126 + }, + "edc2bc2f-56cc-4313-bb59-2ea6d84dadcf": { + "tldrIdentity": "92fd066e-5cca-4363-af35-2993873b426d", + "cwd": "/fixture/project-1", + "kind": "codex", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "providerSessionId": "01a0bc36-1032-7da0-863d-1ad6f63e85f3", + "providerSessionIdSource": "jsonl-entry", + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789864054446 + }, + "2bd51d81-4bbe-4e31-999d-797fc27d30b3": { + "tldrIdentity": "cc77d9de-b086-4af1-acb5-39d72f6c8195", + "cwd": "/fixture/project-1", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789867306111 + }, + "a88b59a3-0898-4d02-b941-f43be354acc7": { + "cwd": "/fixture/project-1", + "kind": "extension-view", + "extensionViewId": "julius-workspace-features.main", + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789886865905, + "builtInMcpOverrides": {} + }, + "dbd157af-851f-46ff-a88c-b7e0b4a0f8ba": { + "cwd": "/fixture/project-1", + "kind": "terminal", + "tmuxName": "agentcode-3ecf1262-5b1c-42e3-ac29-3c081d6fa391", + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789893133675, + "builtInMcpOverrides": {} + }, + "b05e03c1-fc0a-43ad-ac7e-78f3640c7327": { + "tldrIdentity": "0e388bf3-d210-4831-a799-97541ccc4e9a", + "cwd": "/fixture/project-4", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "title": "Title 4", + "orchestrationParentId": "5906040e-f07c-4699-a153-d79647e07edb", + "orchestrationRootId": "5906040e-f07c-4699-a153-d79647e07edb", + "orchestrationRole": "reviewer", + "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "joinedAt": 1789893300288, + "providerSessionId": "07a27d03-c9f3-47c5-9de0-2135e240f31c", + "providerSessionIdSource": "jsonl-entry", + "orchestrationBootstrapPromptDelivered": true + } + } + } + } + ] +} \ No newline at end of file diff --git a/testing/fixtures/workspace-v3/README.md b/testing/fixtures/workspace-v3/README.md new file mode 100644 index 000000000..596a38843 --- /dev/null +++ b/testing/fixtures/workspace-v3/README.md @@ -0,0 +1,35 @@ +# workspace-v3 fixtures + +## `2026-09-20-live-workspace.sanitized.json` + +A real `~/.config/agent-code/workspace.json`, persisted by the running app on +2026-09-20 — the **v3 unified-stage** envelope that #1013 migrated to. It holds +one window, three projects and 13 sessions: 5 claude, 3 opencode, 2 codex, +2 terminal and 1 extension-view. + +The v3 shape differs from v2 in exactly the way the projections care about: + +| | v2 | v3 | +|---|---|---| +| Projects listed under | `tabs` | `projects` | +| Membership | a leaf inside `tab.root`'s tile tree | the session's own `projectId` | +| Layout | `tabs[].root` split tree | `stage` (lanes + pool) | + +So a reader that only understands `tabs`/`root` finds **no projects and no +membership** in this file, while still seeing every session — which is the +failure this fixture exists to catch. `src/main/remote/workspaceProjection.ts` +reaches the phone through `agentActivity/workspaceProjection.ts`, and the phone +had no v3 test at all (#1031 item 4). + +**Sanitization.** Structure and every value are unchanged, except three private +string fields. Each is replaced through a stable one-to-one placeholder map, so +equal values stay equal and distinct values stay distinct: + +| Field | Replaced with | +|---|---| +| `cwd` | `/fixture/project-N` | +| `title` | `Title N` | +| `projectTabTitle` | `Project N` | + +Verified afterwards to contain no absolute home path, username or real +directory name. From 3cadb8c9b5663854a7fc39ec175ba393aaa0dc76 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 20 Sep 2026 02:29:28 -0700 Subject: [PATCH 2/3] test(remote): correct the v3 claim, and pin the phone's join key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1065. The most important finding is that my own justification was false, so it is corrected rather than softened. `agentActivity/workspaceProjection.test.ts` already covers the SHARED decoder on v3 documents, and deleting the v3 `projects` parsing fails two of its tests. I ran that mutation only against `src/main/remote/`, saw it green, and generalised it into "a regression would have been silent". It would not have been. The real gap is narrower and specific to the phone: nothing drove `RemoteWorkspaceProjection` with a v3 document at all. The header, the README and the PR now say that instead. The identity test asserted only `kind` and `cwd`, both pass-throughs. It now asserts `tldrIdentity` — the key the remote server joins every TLDR and Goal frame on, so dropping it silently empties both on the phone while the session list still looks right. Nulling it now fails 2. The re-projection test changes a `tldrIdentity` rather than a `title` for the same reason: that equality check was unpinned, and removing it left all 74 remote tests green. It now fails 1. The kind count said "four or more" under a comment naming five; it says five. The sanitization table was wrong in three ways: it listed `projectTabTitle`, which is v2-only and appears in neither file; it omitted `projects[].title`, which carries three of the placeholders; and it said nothing about `drafts`, which persists RAW COMPOSER TEXT. This capture contained none, so the file is clean — but clean by timing, not by process, and the README now says to drop `drafts` unconditionally when re-capturing. `/fixture/project-N` named directories, not projects — two of them are different directories inside one project, so the numbering never tracked `Title N`. They are `/fixture/dir-N`. The store fake was duplicated verbatim across both projection test files; one definition of "the store notifies only after bytes reach disk" now lives in `workspaceProjection.testSupport.ts`. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/remote/workspaceProjection.test.ts | 23 ++---- .../remote/workspaceProjection.testSupport.ts | 37 +++++++++ src/main/remote/workspaceProjectionV3.test.ts | 77 +++++++++---------- .../2026-09-20-live-workspace.sanitized.json | 26 +++---- testing/fixtures/workspace-v3/README.md | 64 ++++++++++----- 5 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 src/main/remote/workspaceProjection.testSupport.ts diff --git a/src/main/remote/workspaceProjection.test.ts b/src/main/remote/workspaceProjection.test.ts index 025b6022e..c981a1788 100644 --- a/src/main/remote/workspaceProjection.test.ts +++ b/src/main/remote/workspaceProjection.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { PersistedWindow } from '@main/storage/workspaceFile.js' import type { WorkspaceFileStore } from '@main/storage/workspaceFileStore.js' import { RemoteWorkspaceProjection } from './workspaceProjection' +import { fakeWorkspaceFileStore } from './workspaceProjection.testSupport' // The projection is the single join behind every identity fact the phone // shows (title, agent name, tab, pin, TLDR identity). These unit tests pin @@ -17,28 +18,14 @@ type FakeStoreOptions = { type FakeStore = ReturnType +/** Thin adapter onto the shared store fake so both projection test files use + * ONE definition of "the store notifies only after bytes reach disk". */ function fakeStore({ saves = [] }: FakeStoreOptions = {}) { - let windows: readonly PersistedWindow[] = saves[0] ?? [] - let cursor = 1 - const observers = new Set<(w: readonly PersistedWindow[]) => void>() - return { - windows: () => windows, - observe(listener: (w: readonly PersistedWindow[]) => void) { - observers.add(listener) - return () => observers.delete(listener) - }, - /** Test seam: commit the next fixture document, exactly as the real - * store notifies only after bytes reach disk. */ - commitNext() { - windows = saves[cursor] ?? windows - cursor += 1 - for (const observer of observers) observer(windows) - }, - } + return fakeWorkspaceFileStore(saves) } function asStore(store: FakeStore): WorkspaceFileStore { - return store as unknown as WorkspaceFileStore + return store.asStore() } // Name reader seam: pass-through — call sites hand the projection a diff --git a/src/main/remote/workspaceProjection.testSupport.ts b/src/main/remote/workspaceProjection.testSupport.ts new file mode 100644 index 000000000..4ed6f94cd --- /dev/null +++ b/src/main/remote/workspaceProjection.testSupport.ts @@ -0,0 +1,37 @@ +import type { PersistedWindow } from '@main/storage/workspaceFile.js' +import type { WorkspaceFileStore } from '@main/storage/workspaceFileStore.js' + +/** + * A `WorkspaceFileStore` that hands out prepared saves on demand. + * + * WHY this is shared rather than copied into each projection test: the real + * store's contract is that it notifies observers ONLY after bytes reach disk, + * in commit order. Two copies of that fake would drift, and the copy that + * drifted would be the one asserting the freshness contract — which is the + * whole reason `RemoteWorkspaceProjection` is allowed to be one save behind + * the renderer rather than reading live state. + * + * `commitNext()` advances to the next prepared document and notifies, exactly + * as a committed autosave does. + */ +export function fakeWorkspaceFileStore(saves: readonly PersistedWindow[][]) { + let windows: readonly PersistedWindow[] = saves[0] ?? [] + let cursor = 1 + const observers = new Set<(next: readonly PersistedWindow[]) => void>() + return { + windows: () => windows, + observe(listener: (next: readonly PersistedWindow[]) => void) { + observers.add(listener) + return () => observers.delete(listener) + }, + commitNext() { + windows = saves[cursor] ?? windows + cursor += 1 + for (const observer of observers) observer(windows) + }, + /** The projection takes the real interface; the fake is structural. */ + asStore(): WorkspaceFileStore { + return this as unknown as WorkspaceFileStore + }, + } +} diff --git a/src/main/remote/workspaceProjectionV3.test.ts b/src/main/remote/workspaceProjectionV3.test.ts index c05d59f7e..13d00516e 100644 --- a/src/main/remote/workspaceProjectionV3.test.ts +++ b/src/main/remote/workspaceProjectionV3.test.ts @@ -3,15 +3,24 @@ import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { PersistedWindow } from '@main/storage/workspaceFile.js' -import type { WorkspaceFileStore } from '@main/storage/workspaceFileStore.js' import { RemoteWorkspaceProjection } from './workspaceProjection' +import { fakeWorkspaceFileStore } from './workspaceProjection.testSupport' // --------------------------------------------------------------------------- -// #1031 item 4. Every phone-projection test built a v2 document by hand — -// `tabs[]` with a `root` tile tree — so nothing in the suite exercised the -// shape the app has actually been persisting since #1013 migrated to the -// unified stage. The projection DOES handle v3, but a regression in that half -// would have been silent, and the phone's entire session list depends on it. +// #1031 item 4. +// +// WHAT WAS ACTUALLY MISSING — and what was not. `agentActivity/ +// workspaceProjection.test.ts` already covers the SHARED decoder on v3 +// documents (`projectWorkspace — v3 documents (#992)`), built by hand; delete +// the v3 `projects` parsing and two of its tests fail. So a decoder regression +// was never silent, and an earlier version of this comment claiming otherwise +// was wrong. +// +// The real gap is narrower and specific to the phone: nothing drove +// `RemoteWorkspaceProjection` — the phone's own read model, and the class its +// entire session list depends on — with a v3 document at all. Every test here +// built a v2 `tabs[]`/`root` tile tree by hand, which is not what the app has +// persisted since #1013. // // The input is the REAL v3 file the app wrote on 2026-09-20, sanitized // (testing/fixtures/workspace-v3/README.md): one window, three projects, 13 @@ -34,36 +43,13 @@ const WINDOW = RECORDED.windows[0]! const WORKSPACE = (WINDOW as unknown as { workspace: { projects: { id: string; title: string }[] - sessions: Record + sessions: Record } }).workspace -/** Mirrors the v2 file's store seam: the real store notifies only after bytes - * reach disk, and exposes the current windows synchronously. */ -function fakeStore(saves: PersistedWindow[][]) { - let windows: readonly PersistedWindow[] = saves[0] ?? [] - let cursor = 1 - const observers = new Set<(w: readonly PersistedWindow[]) => void>() - return { - windows: () => windows, - observe(listener: (w: readonly PersistedWindow[]) => void) { - observers.add(listener) - return () => observers.delete(listener) - }, - commitNext() { - windows = saves[cursor] ?? windows - cursor += 1 - for (const observer of observers) observer(windows) - }, - } -} - function project(saves: PersistedWindow[][]) { - const store = fakeStore(saves) - const projection = new RemoteWorkspaceProjection( - store as unknown as WorkspaceFileStore, - () => Promise.resolve({}), - ) + const store = fakeWorkspaceFileStore(saves) + const projection = new RemoteWorkspaceProjection(store.asStore(), () => Promise.resolve({})) return { projection, commitNext: () => store.commitNext() } } @@ -119,15 +105,23 @@ describe('the phone projection on a REAL v3 workspace (#1031 item 4)', () => { try { const snapshot = projection.snapshot() const kinds = new Set() + let joined = 0 for (const [sessionId, meta] of Object.entries(WORKSPACE.sessions)) { const identity = snapshot.get(sessionId)! expect(identity.kind).toBe(meta.kind) expect(identity.cwd).toBe(meta.cwd ?? null) + // `tldrIdentity` is the phone's JOIN KEY: the remote server keys every + // TLDR and Goal frame by it, so dropping it silently empties both on + // the phone while the session list still looks correct. kind and cwd + // are pass-throughs and would not catch that. + expect(identity.tldrIdentity).toBe(meta.tldrIdentity ?? null) + if (meta.tldrIdentity) joined += 1 kinds.add(identity.kind) } - // claude, opencode, codex, terminal and extension-view all present, so - // a kind-specific break cannot hide. - expect(kinds.size).toBeGreaterThanOrEqual(4) + expect(joined).toBeGreaterThan(0) + // claude, opencode, codex, terminal and extension-view are all present, + // so a kind-specific break cannot hide. + expect(kinds.size).toBeGreaterThanOrEqual(5) } finally { projection.dispose() } @@ -136,10 +130,15 @@ describe('the phone projection on a REAL v3 workspace (#1031 item 4)', () => { it('re-projects a v3 save the same way it re-projects a v2 one', () => { // The freshness contract, on the shape that is actually being written. const onChange = vi.fn() + // Changing the TLDR identity rather than the title, deliberately: the + // projection's change detection compares identities field by field, and + // the tldrIdentity comparison was unpinned by any test — deleting it left + // all 74 remote tests green. const renamed = JSON.parse(JSON.stringify(WINDOW)) as typeof WINDOW - const sessionId = Object.keys(WORKSPACE.sessions)[0]! - ;(renamed as unknown as { workspace: { sessions: Record } }) - .workspace.sessions[sessionId]!.title = 'Renamed on the stage' + const sessionId = Object.keys(WORKSPACE.sessions) + .find(id => WORKSPACE.sessions[id]!.tldrIdentity)! + ;(renamed as unknown as { workspace: { sessions: Record } }) + .workspace.sessions[sessionId]!.tldrIdentity = '11111111-2222-4333-8444-999999999999' const { projection, commitNext } = project([[WINDOW], [renamed]]) try { @@ -147,7 +146,7 @@ describe('the phone projection on a REAL v3 workspace (#1031 item 4)', () => { onChange.mockClear() commitNext() expect(onChange).toHaveBeenCalled() - expect(projection.snapshot().get(sessionId)?.title).toBe('Renamed on the stage') + expect(projection.snapshot().get(sessionId)?.tldrIdentity).toBe('11111111-2222-4333-8444-999999999999') } finally { projection.dispose() } diff --git a/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json b/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json index e029234c6..a60343a15 100644 --- a/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json +++ b/testing/fixtures/workspace-v3/2026-09-20-live-workspace.sanitized.json @@ -53,7 +53,7 @@ "sessions": { "575880c6-d447-49b8-aa9b-64705d70c287": { "tldrIdentity": "6fb1cb83-be8e-4def-a697-0c1568bccdf7", - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "claude", "builtInMcpDomains": [ "tldr", @@ -71,7 +71,7 @@ }, "e6e19a29-f8b4-44da-bb9a-38fcfca2a314": { "tldrIdentity": "8181bc5b-8eb8-4ee5-b2a0-85c8a9f17e39", - "cwd": "/fixture/project-2", + "cwd": "/fixture/dir-2", "kind": "opencode", "providerRuntime": "terminal", "providerSessionId": "ses_11e052c875fb4a9a85c25fe68823817c", @@ -93,7 +93,7 @@ }, "9bb36de4-39a0-434a-b0b1-00f017d759bd": { "tldrIdentity": "c95d99b5-ce11-4567-ae13-a848117e59d0", - "cwd": "/fixture/project-3", + "cwd": "/fixture/dir-3", "kind": "claude", "builtInMcpDomains": [ "tldr", @@ -111,7 +111,7 @@ }, "1220cbcb-cabf-41b8-a37c-cebd8b506ce7": { "tldrIdentity": "4a8a189b-d663-4190-a82c-d44f5893e86d", - "cwd": "/fixture/project-3", + "cwd": "/fixture/dir-3", "kind": "opencode", "providerRuntime": "terminal", "providerSessionId": "ses_a121d2d5434b4b6bb970ee5dfc170156", @@ -129,7 +129,7 @@ "joinedAt": 1789679456635 }, "a15c8aa6-9d69-4907-ad51-71f7fbe5cc01": { - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "terminal", "tmuxName": "agentcode-b5f36ea3-4d90-4b32-866e-586bfd09b3c7", "builtInMcpOverrides": {}, @@ -138,7 +138,7 @@ }, "6579b6f4-77eb-4813-93dd-2e66a9c46789": { "tldrIdentity": "d4b81a48-1657-4038-8b92-e49107ee22c2", - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "opencode", "providerRuntime": "terminal", "providerSessionId": "ses_6840aca1a7c64d5eaa557982bb32284f", @@ -156,7 +156,7 @@ }, "4d757102-4a13-47c2-9fe0-8a9359cd8323": { "tldrIdentity": "7c218c21-e550-41b6-805e-3399481313fc", - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "codex", "builtInMcpDomains": [ "tldr", @@ -171,7 +171,7 @@ }, "5906040e-f07c-4699-a153-d79647e07edb": { "tldrIdentity": "2776c698-22a4-41ee-bed2-26cd0ac41e20", - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "claude", "providerSessionId": "83a02301-2e9a-42a6-98cb-38cffc008e25", "providerSessionIdSource": "jsonl-entry", @@ -191,7 +191,7 @@ }, "edc2bc2f-56cc-4313-bb59-2ea6d84dadcf": { "tldrIdentity": "92fd066e-5cca-4363-af35-2993873b426d", - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "codex", "builtInMcpDomains": [ "tldr", @@ -209,7 +209,7 @@ }, "2bd51d81-4bbe-4e31-999d-797fc27d30b3": { "tldrIdentity": "cc77d9de-b086-4af1-acb5-39d72f6c8195", - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "claude", "builtInMcpDomains": [ "tldr", @@ -223,7 +223,7 @@ "joinedAt": 1789867306111 }, "a88b59a3-0898-4d02-b941-f43be354acc7": { - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "extension-view", "extensionViewId": "julius-workspace-features.main", "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", @@ -231,7 +231,7 @@ "builtInMcpOverrides": {} }, "dbd157af-851f-46ff-a88c-b7e0b4a0f8ba": { - "cwd": "/fixture/project-1", + "cwd": "/fixture/dir-1", "kind": "terminal", "tmuxName": "agentcode-3ecf1262-5b1c-42e3-ac29-3c081d6fa391", "projectId": "e0224b91-da18-4b20-9cc0-da491569a6b5", @@ -240,7 +240,7 @@ }, "b05e03c1-fc0a-43ad-ac7e-78f3640c7327": { "tldrIdentity": "0e388bf3-d210-4831-a799-97541ccc4e9a", - "cwd": "/fixture/project-4", + "cwd": "/fixture/dir-4", "kind": "claude", "builtInMcpDomains": [ "tldr", diff --git a/testing/fixtures/workspace-v3/README.md b/testing/fixtures/workspace-v3/README.md index 596a38843..366b06190 100644 --- a/testing/fixtures/workspace-v3/README.md +++ b/testing/fixtures/workspace-v3/README.md @@ -15,21 +15,49 @@ The v3 shape differs from v2 in exactly the way the projections care about: | Membership | a leaf inside `tab.root`'s tile tree | the session's own `projectId` | | Layout | `tabs[].root` split tree | `stage` (lanes + pool) | -So a reader that only understands `tabs`/`root` finds **no projects and no -membership** in this file, while still seeing every session — which is the -failure this fixture exists to catch. `src/main/remote/workspaceProjection.ts` -reaches the phone through `agentActivity/workspaceProjection.ts`, and the phone -had no v3 test at all (#1031 item 4). - -**Sanitization.** Structure and every value are unchanged, except three private -string fields. Each is replaced through a stable one-to-one placeholder map, so -equal values stay equal and distinct values stay distinct: - -| Field | Replaced with | -|---|---| -| `cwd` | `/fixture/project-N` | -| `title` | `Title N` | -| `projectTabTitle` | `Project N` | - -Verified afterwards to contain no absolute home path, username or real -directory name. +### What this fixture is for + +`agentActivity/workspaceProjection.test.ts` already covers the shared decoder +on v3 documents it builds by hand. What had **no** coverage was +`RemoteWorkspaceProjection` — the phone's own read model — on a v3 document at +all, and it is the class the phone's entire session list depends on. That is +the narrow gap this closes; see `src/main/remote/workspaceProjectionV3.test.ts`. + +## Sanitization + +Structure and every value are unchanged, except the private string fields +below. Each is replaced through a **stable one-to-one** map, so equal values +stay equal and distinct values stay distinct. + +| Field | Replaced with | Note | +|---|---|---| +| `sessions[].cwd` | `/fixture/dir-N` | **Directories, not projects.** Several directories can belong to one project, so the numbering deliberately does not track `Title N`. | +| `sessions[].title` | `Title N` | shares the counter with the project titles below | +| `projects[].title` | `Title N` | | + +### `drafts` — redact it, unconditionally + +`workspace.drafts` persists **raw composer text** (`workspaceShape.ts`, +written by `useAutoSave.ts`), i.e. whatever the user had typed and not sent. + +This capture happened to contain no drafts, so the committed file is clean — +but it is clean **by timing, not by process**. Anyone re-capturing this fixture +must drop `drafts` explicitly rather than trusting it to be empty. The v2 +fixture redacted it deliberately for the same reason. + +### What is deliberately left verbatim + +UUID-shaped identifiers — `tldrIdentity`, `agentNameId`, `windowId`, +`providerSessionId`, tmux session names — are kept, because the tests join on +them and they are inert without the transcript files they key into. They are +not credentials. If a debug bundle or proxy dump is ever published from the +same machine, they would become a correlation key across those artifacts; +that is the known trade, and the same one the existing v2 owner fixture makes. + +`extensionViewId` names a public repository on the same account that hosts this +one, so it discloses nothing the repository itself does not. + +### Verified after sanitizing + +No absolute path, no `/Users/` fragment, no home directory, no worktree path, +no branch name, no repository or client name, no prompt or goal text. From df7c6c316f3bf2aab79816ebbe5838ffe78878dd Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 20 Sep 2026 02:29:51 -0700 Subject: [PATCH 3/3] fix(remote): accept readonly save arrays in the shared store fake The extracted helper declared a mutable PersistedWindow[][], which the v2 file's own readonly saves could not satisfy. Caught by tsc after the previous commit was already pushed. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/remote/workspaceProjection.testSupport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/remote/workspaceProjection.testSupport.ts b/src/main/remote/workspaceProjection.testSupport.ts index 4ed6f94cd..7433207bc 100644 --- a/src/main/remote/workspaceProjection.testSupport.ts +++ b/src/main/remote/workspaceProjection.testSupport.ts @@ -14,7 +14,7 @@ import type { WorkspaceFileStore } from '@main/storage/workspaceFileStore.js' * `commitNext()` advances to the next prepared document and notifies, exactly * as a committed autosave does. */ -export function fakeWorkspaceFileStore(saves: readonly PersistedWindow[][]) { +export function fakeWorkspaceFileStore(saves: readonly (readonly PersistedWindow[])[]) { let windows: readonly PersistedWindow[] = saves[0] ?? [] let cursor = 1 const observers = new Set<(next: readonly PersistedWindow[]) => void>()