From b69442074b0ea0162505db25c42706c279d06394 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 00:06:08 +0900 Subject: [PATCH 01/31] fix: Cycle 0056 pt3: a test replay chosen in a Study pane inside your group opens instead of being refused (task 17) --- extension/src/studyWorkspace.ts | 4 ++ extension/src/tests/studyWorkspaceHarness.ts | 41 +++++++++++- ...tudyWorkspaceTestReplayComposition.test.ts | 62 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 9b576f97..fd70bb5e 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -8220,9 +8220,13 @@ export class StudyWorkspace { } if (this.liveComposition || this.summaryComposition) { // Live uses the host chat; disposing only our panel never closes another editor's tabs. + const disposedStudyPane = this.contextPanel this.contextPanel?.dispose() this.contextPanel = undefined this.presentationMode = 'study-workspace' + // A Study pane in a learner's group leaves no retired group for the arrangement to drain, yet the editor reports the group's next tab active before it closes the disposed tab; a guard armed meanwhile reads that as the learner's focus change (N55-F4 refusal 2). Every guard therefore starts after the close. + if (disposedStudyPane && this.hasRetiredStudyPaneTab()) + await this.retiredStudyPaneClosed(signal) } this.opened = true this.closing = false diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 1c1e4eb2..b7a3b603 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -53,6 +53,8 @@ const NATIVE_VARIABLES_REFRESH_MS = 400 const NATIVE_VARIABLES_FIRST_RENDER_MS = 60 /** How long the fake editor's worker takes to close a disposed panel's tab when no layout read is answered before it runs; shorter than one poll of the reader's wait for that close. */ const DISPOSED_TAB_CLOSE_DELAY_MS = 20 +/** How long after a pane's disposal the fake Cursor group model reports the next tab active, before it closes the disposed tab (`activateNextTabBeforeDisposedPaneTabCloses`); shorter than {@link DISPOSED_TAB_CLOSE_DELAY_MS}. */ +const NEXT_TAB_ACTIVATION_DELAY_MS = 8 /** How long after a disposed pane's tab closed the fake editor removes the group that tab left empty, in the model of `removeEmptiedGroupsLater`: Cursor 3.23.12 removed the captured source's group 57 ms after its tab at "Codebook: Close Study Workspace", and the Study pane's 12 ms after its tab (Cycle 0046 row 66). The longer of the two is modelled: it is longer than one poll of the reader's wait for the panes' tabs, so the group is still there when that wait ends. */ const EMPTIED_GROUP_CLOSE_DELAY_MS = 57 /** How long after a pane's move into another group the fake editor removes the group the move left empty, in the model of `removeGroupLeftByPanelMoveLater`: VS Code 1.140.0 closes a moved webview's editor in its old group only once the open in the new group has begun (`workbench.desktop.main.js` 4037:7120 calls the old group's `closeEditor` from `onWillOpenEditor`), and that close awaits its confirmation before it removes the emptied group (1106:79300, 1106:79400, 1106:80531); in C58 round 1d the leftover was gone about 130 ms after the Application's move (frames at +0.45 s and +0.58 s, group events +513 to +571 ms of the Return; Cycle 0046 row 658). */ @@ -1086,6 +1088,10 @@ export async function workspaceHarness( /** The removals of groups a pane's move left empty that the fake editor has not run yet; see `removeGroupLeftByPanelMoveLater`. */ const pendingMoveRemovals: (() => void)[] = [] let closeDisposedPanelTabsLater = false + /** Whether a disposed pane that is the front tab of a group shared with other tabs first makes the next tab the active one through a tab notification, and closes its own tab later; see `activateNextTabBeforeDisposedPaneTabCloses`. */ + let activatesNextTabBeforeDisposedPaneTabCloses = false + /** The next-tab notification of a disposed pane that the fake editor has not delivered yet; the first layout read after the disposal delivers it while the read is in flight. */ + let pendingNextTabActivation: (() => void) | undefined // Cursor 3.23.12 keeps naming the chat's group as the active tab group after `workbench.action.focusFirstEditorGroup` gave the main editor the keyboard. Only a group event corrects the name: a whole tab-model rebuild names the active group (`workbench.desktop.main.js` 17269:31013, `extensionHostProcess.js` 131:33177), as does a group's activation (17269:28123, 131:33740); a tab notification is a tab operation, which leaves the named group as it is (131:34068), so it does not (Cycle 0046 row 673). let staleNamedGroup: (typeof groups)[number] | undefined // The tab that had the keyboard was closed while the focus was to be preserved, or its pane was disposed: no editor has the keyboard, the page body does (Cursor 3.23.12 after Close Study Workspace, Cycle 0045 obs15 P4), until something is focused again. @@ -3381,11 +3387,29 @@ export async function workspaceHarness( } } else replaceGroupObjects() } - // VS Code closes a disposed editor from a worker with a 0 ms delay; a layout read that reached the busy window with the disposal is answered before that worker runs. - if ( + // Cursor's group model selects the next tab before it closes the disposed one: `$acceptTabOperation` kind 2 reaches the extension host first, with no group event and the disposed tab still listed (Cycle 0056, N55-F4 refusal 2). + const sharedGroup = groups.find( + (group) => group.activeTab === nativeTab && group.tabs.length > 1, + ) + if (activatesNextTabBeforeDisposedPaneTabCloses && sharedGroup) { + const nextTab = sharedGroup.tabs.find((tab) => tab !== nativeTab)! + // The notification arrives while the first layout read after the disposal is in flight (`pendingNextTabActivation`), or {@link NEXT_TAB_ACTIVATION_DELAY_MS} after the disposal when no read comes. + const activateNextTab = (): void => { + if (pendingNextTabActivation !== activateNextTab) return + pendingNextTabActivation = undefined + sharedGroup.activeTab = nextTab + for (const listener of tabChangeListeners) + listener({ opened: [], closed: [], changed: [nextTab] }) + } + pendingNextTabActivation = activateNextTab + runEditorChangeLater(activateNextTab, NEXT_TAB_ACTIVATION_DELAY_MS) + pendingDisposedTabCloses.push(closeTab) + applyByItselfLater(closeTab) + } else if ( closeDisposedPanelTabsLater || closeDisposedPanelTabsDuringLayoutRead ) { + // VS Code closes a disposed editor from a worker with a 0 ms delay; a layout read that reached the busy window with the disposal is answered before that worker runs. pendingDisposedTabCloses.push(closeTab) // Without a layout read the worker closes the tab by itself a moment later. applyByItselfLater(closeTab) @@ -4298,6 +4322,12 @@ export async function workspaceHarness( if (name === 'workbench.action.toggleMaximizedPanel') returnHiddenEditorArea() if (name === 'vscode.getEditorLayout') { + // Cursor reports the next tab active while a layout read that was armed after the disposal is in flight. + const deliverNextTabActivation = pendingNextTabActivation + if (deliverNextTabActivation) { + await new Promise((resolve) => setImmediate(resolve)) + deliverNextTabActivation() + } if (reactivatesPreviousGroupAtLayoutRead) { layoutReadsUntilReactivation -= 1 if (layoutReadsUntilReactivation <= 0) { @@ -5909,6 +5939,13 @@ export async function workspaceHarness( closeDisposedPanelTabsLater(): void { closeDisposedPanelTabsLater = true }, + /** + * Makes the fake editor, when a pane that is the front tab of a group shared with other tabs is disposed, first make the group's next tab the active one through a tab notification only (no group event, the disposed tab still listed), and close the disposed tab {@link DISPOSED_TAB_CLOSE_DELAY_MS} later, as Cursor's group model does (`doCloseEditor` selects the next tab, then removes the closed one; Cycle 0056, N55-F4 refusal 2). + * @example harness.activateNextTabBeforeDisposedPaneTabCloses() + */ + activateNextTabBeforeDisposedPaneTabCloses(): void { + activatesNextTabBeforeDisposedPaneTabCloses = true + }, /** * Delivers queued native tab changes after a regression changes window focus, for delayed pane-disposal checks. * @returns Once the pending changes ran; their automatic timers then have nothing left to deliver. diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 8aa6efbf..01bf2999 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -13,6 +13,7 @@ import { ReplayDebuggerStartError, StudyError } from '../errors' import { sha256 } from '../runtimeSupport/utils/sha256' import { waitUntil } from '../runtimeSupport/utils/waitUntil' import type { StudyViewMessage } from '../ui/studyHtml' +import type { CodebookDiagnostic } from '../utils/formatCodebookDiagnostic' import { readerSeams, @@ -161,6 +162,67 @@ function compositionOf(harness: Harness) { } } +// Value: protects=N55-F4 refusal 2: a test replay chosen in a Study pane that sits in the learner's own group opens, although Cursor reports the group's next tab as active before it closes the disposed Study tab; fails_when=the entry reads the grid while the disposed Study tab is still listed and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 (the refusal came from Codebook's own disposal, not from the learner); seam=workspaceHarness with activateNextTabBeforeDisposedPaneTabCloses and addForeignDraft in the Study pane's group; no_claim=Cursor's own event order (N56 step 1 checks it natively) +test('a test replay chosen in a Study pane that shares the learner’s group opens without a placement refusal', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.activateNextTabBeforeDisposedPaneTabCloses() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + const studyColumn = harness.panelColumn('codebook.studyContext') + assert.notEqual(studyColumn, undefined) + // The learner's own tab shares the Study pane's group, so the group is no one Codebook created. + harness.addForeignDraft(studyColumn) + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + // A refusal ends the entry without a landing, so the wait ends on either result. + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 || + logged.some( + (entry) => entry.surface === 'presentation:placement-refused', + ), + 'the Saved studies choice lands on the first stop or is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + logged.filter( + (entry) => entry.surface === 'presentation:placement-refused', + ), + [], + ) + await landsOn( + harness, + 2, + 'the Saved studies choice lands on the first stop', + ) + assert.deepEqual(compositionOf(harness), { + studyPane: false, + application: true, + summary: true, + family: 'test', + }) + } finally { + await harness.close() + } +}) + for (const entry of ['Open replay', 'Saved studies'] as const) { // Value: protects=the learner's entry into a test replay in VS Code gives region 2's composition: the Study pane closes, the Application pane and the recorded source stay, and the Learning summary shows in the chat's place, moved beside the chat once per profile; fails_when=the Study pane stays open with the replay in its Test replay section, the summary keeps its placeholder, or the move does not run; why_new=Cycle 0051 DS-A (AC1): a test replay entered the Study composition; seam=workspaceHarness (VS Code), openLearningReplay and the Study pane's node-history message, the fake vscode.moveViews; no_claim=the native placement, which N51 (a) checks test(`${entry} in VS Code opens a test replay with the Study pane closed and the Learning summary beside the chat`, async () => { From 6f1888302f02864710f90574133cce08aacebe47 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 00:08:36 +0900 Subject: [PATCH 02/31] fix: Cycle 0056 pt3: a refused test replay no longer blocks your next Saved studies choice behind its unanswered notice (task 17) --- extension/src/studyWorkspace.ts | 34 +++++---- .../studyWorkspaceTestReplayOpens.test.ts | 70 +++++++++++++++++++ 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index fd70bb5e..9f55fd42 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11898,7 +11898,7 @@ export class StudyWorkspace { if (entry !== this.nodeHistoryEntryRequest) return false // A refused entry is told like a failed replay open: one Output › Codebook entry and the notification, never a bare command failure (F382, F384). await this.leaveEmptySummaryComposition() - await this.tellTestReplayOpenFailure( + this.tellTestReplayOpenFailure( studyId, error, sanitizeCause(error, this.privateRoots()), @@ -11977,7 +11977,7 @@ export class StudyWorkspace { profile: NODE_TEST_PROFILE, cause, }) - else await this.tellTestReplayOpenFailure(studyId, error, cause) + else this.tellTestReplayOpenFailure(studyId, error, cause) return 'told' } @@ -12042,17 +12042,18 @@ export class StudyWorkspace { } /** - * Tells the learner that a test replay did not open: one Output › Codebook entry and the load-failure notification, whose Show details button opens the channel; called by {@link openNodeHistoryFromHost}. + * Tells the learner that a test replay did not open: one Output › Codebook entry, written before this returns, and the load-failure notification, whose Show details button opens the channel; called by {@link openNodeHistoryFromHost} and {@link enterCompositionForEntry}. + * The notification's answer is never awaited: an error notification stays on screen until the learner answers it, and the entry that raised it holds the next entry's turn, so waiting would leave every later choice with no notice and no Output entry (N55-F4, Cycle 0056 task 17). The answer opens the channel whenever it comes. * @param studyId - The test replay's study id. * @param error - What the open threw. * @param cause - The sanitized cause of that error. - * @example await this.tellTestReplayOpenFailure(studyId, outcome.error, cause) + * @example this.tellTestReplayOpenFailure(studyId, outcome.error, cause) */ - private async tellTestReplayOpenFailure( + private tellTestReplayOpenFailure( studyId: string, error: unknown, cause: string, - ): Promise { + ): void { const stack = sanitizedStack(error, this.privateRoots()) this.diagnostics?.log({ surface: 'open:test-replay', @@ -12071,14 +12072,21 @@ export class StudyWorkspace { : studyId, cause, }) - const action = await showPlainNotice( - vscode.window, - 'error', - failure.text, - ...(failure.actions ?? []), + // The answer is handled whenever it arrives, outside every entry's turn. + void Promise.resolve( + showPlainNotice( + vscode.window, + 'error', + failure.text, + ...(failure.actions ?? []), + ), + ).then( + (action) => { + if (action === NODE_LEARNING_SAVE_ACTION.showDetails) + this.diagnostics?.show() + }, + (error: unknown) => this.report('control', error), ) - if (action === NODE_LEARNING_SAVE_ACTION.showDetails) - this.diagnostics?.show() } /** diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index b0f20eec..cfa76f26 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { waitUntil } from '../runtimeSupport/utils/waitUntil' +import type { CodebookDiagnostic } from '../utils/formatCodebookDiagnostic' import { readerSeams, @@ -1257,3 +1258,72 @@ test('a test replay entry overtaken during its slot close asks no notice for a b await harness.close() } }) + +// Value: protects=N55-F4's silent no-op: a refused test replay entry whose failure notification the learner has not answered writes its Output entry at once and lets the next Saved studies choice open its replay; fails_when=the next choice waits behind the unanswered notification, so no replay opens and no Output entry is written until a reload; why_new=Cycle 0056 task 17 (the refusal notice held the entry turn); seam=workspaceHarness with an error notification that never settles, the entry's composition step refused once, the diagnostics log; no_claim=Cursor keeping the notification on screen (N56 step 1 checks it natively) +test('a Saved studies choice opens its replay while the notification of a refused earlier choice is unanswered', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + // The learner leaves the failure notification on screen: it never settles. + harness.answerErrorDialogs(() => new Promise(() => undefined)) + // The first entry's composition step is refused once, as the editor's placement guard did natively. + const enterComposition = harness.reader[ + 'enterSummaryCompositionForReplay' + ].bind(harness.reader) + let isFirstEntry = true + harness.reader['enterSummaryCompositionForReplay'] = async ( + ...entryArguments + ): Promise => { + if (isFirstEntry) { + isFirstEntry = false + throw new Error( + 'Native pane, group or focus changed while presentation waited; the learner arrangement was preserved.', + ) + } + return enterComposition(...entryArguments) + } + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + await waitUntil( + () => harness.errorMessages.length === 1, + 'the refused first choice raises its notification', + READER_RESULT_BOUND_MS, + ) + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + + // Assert + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the second choice lands on the first stop behind the unanswered notification', + READER_RESULT_BOUND_MS, + ) + assert.equal( + logged.filter((entry) => entry.surface === 'open:test-replay').length, + 1, + ) + assert.equal(harness.errorMessages.length, 1) + } finally { + await harness.close() + } +}) From 51b9755f9fb80809025d20c50bad4b2737377b8f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 00:12:52 +0900 Subject: [PATCH 03/31] fix: Cycle 0056 pt3: a Saved studies choice dropped after Close Study Workspace leaves an Output entry and frees the pane (task 17) --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 69 ++++++++++++++----- ...tudyWorkspaceTestReplayComposition.test.ts | 48 +++++++++++++ .../studyWorkspaceTestReplayOpens.test.ts | 41 +++++++++++ 4 files changed, 142 insertions(+), 18 deletions(-) diff --git a/extension/README.md b/extension/README.md index 4345e1b0..70a95e50 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 9f55fd42..ad418449 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -23126,13 +23126,18 @@ export class StudyWorkspace { void webview.postMessage(reply) } - /** Accepts only bounded named UI messages, rejecting stale controls before dispatch. @example await this.receive(message) */ - private async receive( - value: unknown, + /** + * Says whether a webview message comes from a pane the reader no longer answers: a renderer a handoff retired, or the Learning summary while the Study workspace is closed or has left the summary composition; called twice by {@link receive}, before and after its initialization. + * @param origin - The pane family the webview belongs to. + * @param owner - The panel or view whose webview sent the message; `undefined` for a message the reader delivers itself. + * @returns `true` when the message is to be dropped. + * @example if (this.isMessageOwnerRetired('context', owner)) return + */ + private isMessageOwnerRetired( origin: 'context' | 'moments', owner?: vscode.WebviewPanel | vscode.WebviewView, - ): Promise { - if ( + ): boolean { + return Boolean( owner && (owner !== (origin === 'moments' @@ -23141,9 +23146,47 @@ export class StudyWorkspace { ? this.summaryView : this.contextPanel) || (owner === this.summaryView && - (!this.opened || !this.summaryComposition || this.closing))) + (!this.opened || !this.summaryComposition || this.closing))), ) + } + + /** + * Writes one Output › Codebook entry for a learner's choice (a Saved studies entry, a tab or an item) that the Learning summary sent while the Study workspace was closed, so a dropped choice leaves a trace instead of nothing (N55-F4, Cycle 0056 task 17); called by {@link receive} when {@link isMessageOwnerRetired} drops a message. + * The pane that sent the choice is released by {@link receivePaneMessage}'s reply, which is posted whether or not the message was carried out. A renderer a handoff retired is no learner's choice and writes nothing. + * @param value - The raw message that was dropped. + * @param owner - The view that sent it. + * @example this.noteDroppedSummaryChoice(message, owner) + */ + private noteDroppedSummaryChoice( + value: unknown, + owner?: vscode.WebviewPanel | vscode.WebviewView, + ): void { + if (!owner || owner !== this.summaryView) return + const parsed = messageSchema.safeParse(value) + if ( + !parsed.success || + !CONTROL_RETIRING_MESSAGE_TYPES.has(parsed.data.type) + ) + return + this.diagnostics?.log({ + surface: 'choice:dropped', + ...('studyId' in parsed.data && typeof parsed.data.studyId === 'string' + ? { studyId: parsed.data.studyId } + : {}), + cause: `The "${parsed.data.type}" choice from the Learning summary was not carried out: the Study workspace is closed. Open Study Workspace, then choose it again.`, + }) + } + + /** Accepts only bounded named UI messages, rejecting stale controls before dispatch. @example await this.receive(message) */ + private async receive( + value: unknown, + origin: 'context' | 'moments', + owner?: vscode.WebviewPanel | vscode.WebviewView, + ): Promise { + if (this.isMessageOwnerRetired(origin, owner)) { + this.noteDroppedSummaryChoice(value, owner) return + } const parsed = messageSchema.safeParse(value) if (!parsed.success) throw new Error('Unsupported reader message.') const message = parsed.data @@ -23163,18 +23206,10 @@ export class StudyWorkspace { } await this.initialize() // Initialization can outlive a panel handoff; retired renderers never acknowledge or control its successor. - if ( - owner && - (owner !== - (origin === 'moments' - ? this.moments - : this.summaryComposition - ? this.summaryView - : this.contextPanel) || - (owner === this.summaryView && - (!this.opened || !this.summaryComposition || this.closing))) - ) + if (this.isMessageOwnerRetired(origin, owner)) { + this.noteDroppedSummaryChoice(value, owner) return + } if (message.type === 'ready') { this.lastPublished = undefined this.publish() diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 01bf2999..2ce0780e 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -223,6 +223,54 @@ test('a test replay chosen in a Study pane that shares the learner’s group ope } }) +// Value: protects=N55-F4's silent drop: a Saved studies choice sent from the Learning summary after Close Study Workspace is recorded in Output › Codebook with its study, and the sending pane gets its open-request-settled reply; fails_when=the reader drops the choice with no Output entry and no reply, so the learner sees nothing and the pane keeps its opening choice; why_new=Cycle 0056 task 17 (receive's owner gate returned silently for the Learning summary of a closed workspace); seam=workspaceHarness (VS Code), the Learning summary view's listener, the diagnostics log, the harness's recorded open-request replies; no_claim=the wording the learner reads in a real Output channel +test('a Saved studies choice sent from the Learning summary after the Study workspace closed is recorded and releases the pane', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const history = await openByOpenReplay(harness) + const revision = lastView(harness)!.revision + await harness.reader.close() + + // Act + harness.receive({ + type: 'node-history', + revision, + studyId: history.studyId, + openRequest: 5, + }) + await waitUntil( + () => harness.openRequestReplies().length === 1, + 'the Learning summary gets the reply to its dropped choice', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual(harness.openRequestReplies(), [ + { webview: 'summary', openRequest: 5 }, + ]) + // The entry is copied into this realm's plain objects: the reader runs in the harness's own realm. + assert.deepEqual( + logged.map((entry) => ({ ...entry })), + [ + { + surface: 'choice:dropped', + studyId: history.studyId, + cause: + 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. Open Study Workspace, then choose it again.', + }, + ], + ) + } finally { + await harness.close() + } +}) + for (const entry of ['Open replay', 'Saved studies'] as const) { // Value: protects=the learner's entry into a test replay in VS Code gives region 2's composition: the Study pane closes, the Application pane and the recorded source stay, and the Learning summary shows in the chat's place, moved beside the chat once per profile; fails_when=the Study pane stays open with the replay in its Test replay section, the summary keeps its placeholder, or the move does not run; why_new=Cycle 0051 DS-A (AC1): a test replay entered the Study composition; seam=workspaceHarness (VS Code), openLearningReplay and the Study pane's node-history message, the fake vscode.moveViews; no_claim=the native placement, which N51 (a) checks test(`${entry} in VS Code opens a test replay with the Study pane closed and the Learning summary beside the chat`, async () => { diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index cfa76f26..e584db4e 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1327,3 +1327,44 @@ test('a Saved studies choice opens its replay while the notification of a refuse await harness.close() } }) + +// Value: protects=N55-F4's held pane choice: the Study pane that sent a Saved studies choice is released (it gets its open-request-settled reply) when the entry is refused although the learner has not answered the failure notification; fails_when=the reply waits for the notification's answer, so the pane keeps its opening choice and fires no change for the same entry; why_new=Cycle 0056 task 17 (the reply is posted only after the handled message resolved); seam=workspaceHarness with an error notification that never settles, the entry's composition step refused once, the harness's recorded open-request replies; no_claim=the pane's own list rendering (Reader DOM) +test('the Study pane is released from a refused Saved studies choice while the failure notification is unanswered', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + harness.answerErrorDialogs(() => new Promise(() => undefined)) + harness.reader['enterSummaryCompositionForReplay'] = + async (): Promise => { + throw new Error( + 'Native pane, group or focus changed while presentation waited; the learner arrangement was preserved.', + ) + } + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + openRequest: 1, + }) + await waitUntil( + () => harness.openRequestReplies().length === 1, + 'the Study pane gets the reply to its refused choice', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual(harness.openRequestReplies(), [ + { webview: 'context', openRequest: 1 }, + ]) + assert.equal(harness.errorMessages.length, 1) + } finally { + await harness.close() + } +}) From b0a83ec32be66197b2f13c515920f6b4892b525f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 00:31:58 +0900 Subject: [PATCH 04/31] fix: Cycle 0056 pt3: a refused placement now says which call, which comparison and which of Codebook's own effects preceded it (task 17) --- extension/README.md | 2 +- extension/src/constants.ts | 3 + extension/src/studyWorkspace.ts | 798 ++++++++++++------ .../src/tests/failedPlacementChecks.test.ts | 77 ++ .../src/tests/placementRefusalReport.test.ts | 119 +++ .../studyWorkspaceEntryObstacles.test.ts | 2 +- .../studyWorkspaceEntryPlacement.test.ts | 111 +++ extension/src/utils/failedPlacementChecks.ts | 50 ++ extension/src/utils/placementRefusalReport.ts | 114 ++- 9 files changed, 1005 insertions(+), 271 deletions(-) create mode 100644 extension/src/tests/failedPlacementChecks.test.ts create mode 100644 extension/src/utils/failedPlacementChecks.ts diff --git a/extension/README.md b/extension/README.md index 70a95e50..0eaa5356 100644 --- a/extension/README.md +++ b/extension/README.md @@ -98,7 +98,7 @@ The executed source shows in one **recorded-source tab**, named after the stop's The time slider marks the recording between its first and last event as unrecorded, with a tick at each stop whose recorded source verifies, under `Ticks mark recorded stops; source and debugger are not recorded between them.` (a test journal records when a stop paused, not when the test resumed). **Play** moves from event to event and waits at most 1.5 seconds for the next one, also across an unrecorded interval, which it neither pauses at nor asks about; each surface shows the selected event's own time. -Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused, the editor groups before and after, and the group changes the editor reported meanwhile; the sentence shown in the Study pane is unchanged. +Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused (and, for the focus guard, the name of the call that armed it and the comparisons that failed), the editor groups before and after with the tab each group showed, the focused tab when the guard was armed against the active tab at the refusal, the changes the editor reported meanwhile (group changes, tab operations, view-state reports of Codebook's panes and the active text editor) and the reveals, disposals, editor commands and shown documents Codebook itself issued meanwhile; the sentence shown in the Study pane is unchanged. The bundled skill (since 1.0.48) routes a question about a test, or about library behavior a test exercises, to the `node-test` profile, and the chat explains each test stop in the chat. A value the chat asks for at a test stop is also read when it is a name the stopped file imports (`revalidatePath.mock.calls.length` of a mocked `next/cache`, for example), through the module Vitest binds it to, as the export its import declaration names: an alias reads the module's original export and a default import its default export, each from that declaration's own module, never from another module that happens to export the same name; a namespace import, a name the file does not import, and an import whose module the stop cannot see stay unavailable rather than guessed. A getter on the asked path (a Vitest mock's `mock`, for example) runs once, as it does when you expand it in the Variables view; a getter the debugger cannot expand, or expands to no single value, leaves only that value unavailable, never its `(...)` placeholder, and a local variable whose value cannot be read still takes priority over an import of the same name. A required value that still cannot be read no longer ends the run: the stop keeps its other values and names the missing one among its debugger limitations, and the `codebook_continue` that reached it (or your Continue in the Study pane) answers `EVIDENCE_UNAVAILABLE` naming it while the run stays paused there, so the chat, or you, can continue without it; the run's history then seals partial, with a reason that names the value and its stop, and the study reads partial although the test passed. A run that ends with an `error` before its first stop reached no stop and explains nothing (a refusal before the launch ran no test; a test that exited before that stop names its exit code or signal): the chat starts it again only where the published recovery of that error's code allows (`codebook://help/errors`), and it treats any text the error quotes, such as test or tool output, as data. Declared stops are reached in the order the test runs their lines: a later stop on a line the test ran before the previous stop is reached only if the test runs that line again after it (in a loop, for example); otherwise it is never reached, and the continuation then says `Owned Node test exited with code (or signal ) before declared stop .`, naming that stop, instead of only that the debug session ended; the node-test help tells the chat to choose stops in that order, and names the exit code or signal of a test that ends before a stop. A test that ends before its first declared stop says `Owned Node test exited with code (or signal ) before declared stop 1.`, then that the first declared stop is reached only when the test runs its line. That sentence comes only when the test ran on and did not reach the stop: a refusal before the test runs on (after you stopped the debug session yourself, for example) and a failure while a reached stop's values are captured keep their own words. The chat claims saved learning only when the run reads `studyOutcome` `demonstrated`, `nodeTest.learningCoverage` `complete` and an empty `nodeTest.captureMissing` (a run resource without those fields supports no such claim), says when a test did not pass and names its exit code or signal, and points you to **Save learning replay** and **Open replay**. When the connection is unavailable, it sends you to **Codebook: Set Up AI Chat**, and in a chat of VS Code's Copilot harness (the default for a new chat), which cannot reach Codebook's tools, to open a Local chat session. diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 2f63daeb..5af6bbbb 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1851,3 +1851,6 @@ export const REFUSAL_REPORT_LABEL_CHARS = 48 /** The most editor events a {@link PlacementEventLog} keeps and a placement refusal report lists, in events. */ export const REFUSAL_REPORT_EVENTS = 24 + +/** The most native effects Codebook itself issued (reveals, disposals, editor commands, shown documents) that a {@link PlacementEventLog} keeps for a placement refusal report, in effects. */ +export const REFUSAL_REPORT_EFFECTS = 48 diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ad418449..87ee74d2 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -86,6 +86,7 @@ import { SUMMARY_PLACEMENT_ACTION, FOREIGN_PREVIEW_ACTION, MOVE_VIEWS_COMMAND, + REFUSAL_REPORT_EFFECTS, REFUSAL_REPORT_GROUP_TABS, SUMMARY_BESIDE_CHAT_MOVE_ARGUMENT, SUMMARY_BESIDE_CHAT_MOVED_KEY, @@ -247,6 +248,7 @@ import { editorTabInputKinds } from './utils/editorTabInputKinds' import { emptyFirstGroupObstacle } from './utils/emptyFirstGroupObstacle' import { emptyGroupsGoneSince } from './utils/emptyGroupsGoneSince' import { evidenceInUseElsewhere } from './utils/evidenceInUseElsewhere' +import { failedPlacementChecks } from './utils/failedPlacementChecks' import { firstVerifiedStopSequence } from './utils/firstVerifiedStopSequence' import { focusEditorGroupCommand } from './utils/focusEditorGroupCommand' import { focusWebview } from './utils/focusWebview' @@ -301,7 +303,9 @@ import { parseRecordedStudyGroups } from './utils/parseRecordedStudyGroups' import { describePlacementRefusal, PlacementEventLog, + type RefusalEvent, type RefusalGroupView, + type RefusalTabView, } from './utils/placementRefusalReport' import { plannedStopLine } from './utils/plannedStopLine' import { projectLearningSummary } from './utils/projectLearningSummary' @@ -979,6 +983,10 @@ export class StudyWorkspace { private hostOpeningTestReplay = false /** The editor's recent group and active text editor changes, for {@link refusePlacement}'s report (F382). */ private readonly placementEvents = new PlacementEventLog() + /** The native effects this host itself issued (reveals, disposals, editor commands, shown documents), for {@link refusePlacement}'s report (N55-F4). */ + private readonly placementEffects = new PlacementEventLog( + REFUSAL_REPORT_EFFECTS, + ) /** * The explicit Live intent {@link returnCurrentSelection} records for an open reader, so Open and cold reentry, also after a Close, prepare the chat layout before a runtime starts: the record that was current then, and whether it had stopped. A Close keeps it, and so does a Return that arranged nothing, since the selection either leaves follows the current run in Live; a run that starts after it and the stop of the current run end it ({@link isLiveCompositionRequested}, Cycle 0046 OCR1-8). */ @@ -2418,9 +2426,9 @@ export class StudyWorkspace { const expected: unknown = JSON.parse(receipt.signature) // Native pixel rounding is no resize; a ratio the learner changed is. if (isProportionalEditorLayout(expected, layout)) return true - await vscode.commands.executeCommand('vscode.setEditorLayout', expected) + await this.executeEditorCommand('vscode.setEditorLayout', expected) assertCurrent() - const restored = await vscode.commands.executeCommand( + const restored = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertCurrent() @@ -2542,7 +2550,7 @@ export class StudyWorkspace { (matchesRecordedAppGroup() || (recordedApplication.visible && matchesRecordedSourceGroup())), }) - recordedApplication.reveal(recordedApplication.viewColumn, true) + this.revealPane(recordedApplication, recordedApplication.viewColumn, true) await waitUntil( () => recordedApplication === this.app && recordedApplication.visible, 'recorded Application visibility during Restore', @@ -3004,7 +3012,7 @@ export class StudyWorkspace { if (this.enlargedLayoutReceipt !== enlarged || !retained()) return this.enlargedLayoutReceipt = undefined // A late switch between owned Study and Application changes focus, but leaves the measured main grid intact. - const layout = await vscode.commands.executeCommand( + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertCurrent() @@ -3015,14 +3023,14 @@ export class StudyWorkspace { ) return // Editor-layout reads omit maximization, so reapplying the measured grid must clear it before confirmation. - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'vscode.setEditorLayout', JSON.parse(receipt.signature), ) // The measured layout is the one the Enlarge began from: the sizes kept for a Close are given back with it. this.groupSizesBeforeExpand = undefined assertCurrent() - const restoredLayout = await vscode.commands.executeCommand( + const restoredLayout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertCurrent() @@ -3359,7 +3367,7 @@ export class StudyWorkspace { await this.focusPresentationPanel(placedApplication, signal) return } - const restored = await vscode.window.showTextDocument(document, { + const restored = await this.showTextDocumentNoted(document, { viewColumn: currentGroup.viewColumn, preserveFocus: false, preview, @@ -3377,7 +3385,7 @@ export class StudyWorkspace { const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab if (!activeTab || activeTab === activatedByClose) return async (): Promise => { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.focusActiveEditorGroup', ) } @@ -3759,7 +3767,7 @@ export class StudyWorkspace { chatEditorTab: boolean, ): Promise { if (!command) return false - await vscode.commands.executeCommand(command) + await this.executeEditorCommand(command) // Only a chat is told about; a learner's page of the main editor that got its group's focus back is not. if (toldCause && (chatEditorTab || command === CURSOR_FOCUS_AGENT_COMMAND)) this.tellChatKeyboardReturned(toldCause) @@ -3902,7 +3910,7 @@ export class StudyWorkspace { group: vscode.TabGroup, ): Promise { const count = editorLayoutGroupCount( - await vscode.commands.executeCommand('vscode.getEditorLayout'), + await this.executeEditorCommand('vscode.getEditorLayout'), ) // A missing layout never grants main-part membership. if (count === undefined) return 'unknown' @@ -5048,9 +5056,10 @@ export class StudyWorkspace { : undefined // Without a width nothing can be compared: the primary side bar stays as the learner has it. if (widthPx === undefined) return layoutBeforeRead - await vscode.commands - .executeCommand('workbench.action.closeSidebar') - .then(undefined, () => undefined) + await this.executeEditorCommand('workbench.action.closeSidebar').then( + undefined, + () => undefined, + ) const widthWithoutPrimaryPx = await this.readEditorAreaWidthPx() // The measurement hid the primary side bar and cannot tell whether it was open: the end of the reading shows it again rather than leave the learner's bar away. if (widthWithoutPrimaryPx === undefined) { @@ -5089,7 +5098,7 @@ export class StudyWorkspace { * @throws What `assertUnchanged` throws; the sizes then stay kept, for the next Restore or the Close. * @example * ```ts - * await vscode.commands.executeCommand('workbench.action.minimizeOtherEditors') + * await this.executeEditorCommand('workbench.action.minimizeOtherEditors') * await this.giveGroupSizesBackAfterExpand() // two rows of 617 and 70 px are 344 and 343 px again * ``` */ @@ -5104,9 +5113,10 @@ export class StudyWorkspace { this.groupSizesBeforeExpand = undefined // Only the main editor's groups of before get their sizes back. if (!this.hasMainEditorGroupsOf(before, now)) return - await vscode.commands - .executeCommand('vscode.setEditorLayout', before) - .then(undefined, () => undefined) + await this.executeEditorCommand('vscode.setEditorLayout', before).then( + undefined, + () => undefined, + ) } /** @@ -5151,9 +5161,10 @@ export class StudyWorkspace { */ private async readMaximizedEditorAreaWidthPx(): Promise { return maximizedEditorAreaWidthPx( - await vscode.commands - .executeCommand('vscode.getEditorLayout') - .then(undefined, () => undefined), + await this.executeEditorCommand('vscode.getEditorLayout').then( + undefined, + () => undefined, + ), ) } @@ -5220,7 +5231,7 @@ export class StudyWorkspace { ): Promise { // A measured reading hides both side bars with the group's maximization; one whose measurement failed after hiding the primary bar leaves the secondary bar alone. if (this.sideBarsBeforeRead?.widthWithoutPrimaryPx !== undefined) { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.maximizeEditorHideSidebar', ) return @@ -5229,7 +5240,7 @@ export class StudyWorkspace { await this.expandKeepingGroupSizes(this.layoutBeforeReading) await this.maximizeAfterExpand(assertReadFocus, async () => { assertReadFocus() - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.toggleMaximizeEditorGroup', ) }) @@ -5244,9 +5255,7 @@ export class StudyWorkspace { * @example await this.expandKeepingGroupSizes(this.layoutBeforeReading) */ private async expandKeepingGroupSizes(layoutBefore: unknown): Promise { - await vscode.commands.executeCommand( - 'workbench.action.minimizeOtherEditors', - ) + await this.executeEditorCommand('workbench.action.minimizeOtherEditors') this.groupSizesBeforeExpand ??= layoutBefore } @@ -5270,7 +5279,7 @@ export class StudyWorkspace { * @param maximize - The caller's steps after its Expand. * @returns Once the steps are done. * @throws What a step throws, after the sizes were given back or left kept. - * @example await this.maximizeAfterExpand(assertReadFocus, async () => { await vscode.commands.executeCommand('workbench.action.toggleMaximizeEditorGroup') }) + * @example await this.maximizeAfterExpand(assertReadFocus, async () => { await this.executeEditorCommand('workbench.action.toggleMaximizeEditorGroup') }) */ private async maximizeAfterExpand( assertUnchanged: () => void, @@ -5347,7 +5356,7 @@ export class StudyWorkspace { * @returns `true` when a bar was shown and the active editor group focused again, `false` when no Read's measurement was pending or no bar is to be shown. * @example * ```ts - * await vscode.commands.executeCommand('workbench.action.minimizeOtherEditors') + * await this.executeEditorCommand('workbench.action.minimizeOtherEditors') * this.maximizedByRead = false * await this.showSideBarsHiddenByRead() * ``` @@ -5358,7 +5367,7 @@ export class StudyWorkspace { this.sideBarsBeforeRead = undefined const showCommands = await this.sideBarShowCommands(before) const run = (command: string): Thenable => - vscode.commands.executeCommand(command).then(undefined, () => undefined) + this.executeEditorCommand(command).then(undefined, () => undefined) for (const command of showCommands) await run(command) if (!showCommands.length) return false await run('workbench.action.focusActiveEditorGroup') @@ -5452,9 +5461,10 @@ export class StudyWorkspace { ): Promise<{ auxiliary: boolean; primary: boolean }> { if (widthPx === undefined) return { auxiliary: false, primary: before.primaryVisible } - await vscode.commands - .executeCommand('workbench.action.closeSidebar') - .then(undefined, () => undefined) + await this.executeEditorCommand('workbench.action.closeSidebar').then( + undefined, + () => undefined, + ) const widthWithoutPrimaryPx = await this.readEditorAreaWidthPx() return { auxiliary: this.hasSideBarGain( @@ -5485,9 +5495,10 @@ export class StudyWorkspace { * @example const layout = await this.readEditorLayout() // => { orientation: 1, groups: [{ size: 344 }, { size: 343 }] } */ private async readEditorLayout(): Promise { - return vscode.commands - .executeCommand('vscode.getEditorLayout') - .then(undefined, () => undefined) + return this.executeEditorCommand('vscode.getEditorLayout').then( + undefined, + () => undefined, + ) } /** @@ -5594,7 +5605,9 @@ export class StudyWorkspace { restoreFocusAttempted = false let assertViewportOwnership: (() => void) | undefined let assertRetainedPlacement = this.opened - ? this.retainPresentationPlacement() + ? this.retainPresentationPlacement( + 'performPresentation/assertRetainedPlacement', + ) : undefined this.presentationMovedFocus = false this.presentationYieldedToLearner = false @@ -5683,7 +5696,9 @@ export class StudyWorkspace { const entered = this.confirmEnteredComposition(input) // A cold default Study already arranged around its prepared App; keep every original group instead of reapplying the three-leaf legacy placement. if (!this.liveComposition && !this.summaryComposition) { - const assertEntry = this.retainPresentationPlacement() + const assertEntry = this.retainPresentationPlacement( + 'performPresentation/assertEntry', + ) await this.savePresentationPreference(input.mode, transition.id) assertCurrent() assertEntry() @@ -5693,7 +5708,9 @@ export class StudyWorkspace { this.tellUntoldSelectionKeyboard(requester) return { ...entered, preference: this.presentationState().preference } } - assertRetainedPlacement ??= this.retainPresentationPlacement() + assertRetainedPlacement ??= this.retainPresentationPlacement( + 'performPresentation/assertRetainedPlacement', + ) this.presentationPoseRequest = undefined this.presentationPose = undefined this.presentationPoseRestored = undefined @@ -5805,7 +5822,9 @@ export class StudyWorkspace { ) assertCurrent() if (!this.app?.active && !this.contextPanel?.active) assertSourceFocus() - assertRetainedPlacement = this.retainPresentationPlacement() + assertRetainedPlacement = this.retainPresentationPlacement( + 'performPresentation/assertRetainedPlacement', + ) for (const release of releaseViewports.splice(0)) release() // Native movement may recreate the renderer; it must acknowledge the same evidence after the lock is released. if (this.recordingActive) { @@ -5851,9 +5870,11 @@ export class StudyWorkspace { if (!this.keepsKeyboardAfterAgentSelection(agentSelection)) await restoreFocus?.(signal, assertSourceFocus, true) assertCurrent() - assertRetainedPlacement = this.retainPresentationPlacement() + assertRetainedPlacement = this.retainPresentationPlacement( + 'performPresentation/assertRetainedPlacement', + ) const receipt = this.layoutReceipt, - actual = await vscode.commands.executeCommand( + actual = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertCurrent() @@ -6123,8 +6144,13 @@ export class StudyWorkspace { ): Promise { const lifetime = this.lifetime, transition = this.presentationTransition - const assertPlacement = this.retainPresentationPlacement(false), - assertInitialFocus = this.retainPresentationPlacement() + const assertPlacement = this.retainPresentationPlacement( + 'focusPresentationPanel/assertPlacement', + false, + ), + assertInitialFocus = this.retainPresentationPlacement( + 'focusPresentationPanel/assertInitialFocus', + ) const kind = panel === this.app ? 'codebook.scopedBrowser' @@ -6238,7 +6264,7 @@ export class StudyWorkspace { return } // The keyboard may be outside the editor area while the pane is the active editor: the pane takes it, and nothing waits for its document. - if (!this.reportsKeyboard(panel)) panel.reveal(undefined, false) + if (!this.reportsKeyboard(panel)) this.revealPane(panel, undefined, false) } /** @@ -6322,7 +6348,10 @@ export class StudyWorkspace { const selection = JSON.stringify(this.selection), bundle = this.recordingActive ? this.playback?.bundle() : undefined const focusBeforeRead = this.readingFocusOrigin() - const assertPlacement = this.retainPresentationPlacement(false) + const assertPlacement = this.retainPresentationPlacement( + 'readStudyPane/assertPlacement', + false, + ) if (region === 'source') { const sourceColumn = this.studyPanePositions().sourceColumn if (this.sourceReady() && this.sourceUri && sourceColumn) { @@ -6337,7 +6366,7 @@ export class StudyWorkspace { } finally { originalTab.dispose() } - await vscode.window.showTextDocument(document, { + await this.showTextDocumentNoted(document, { viewColumn: sourceColumn, preserveFocus: false, preview: false, @@ -6367,7 +6396,7 @@ export class StudyWorkspace { const kind = region === 'app' ? 'codebook.scopedBrowser' : 'codebook.studyContext' assertReadFocus() - panel.reveal(undefined, false) + this.revealPane(panel, undefined, false) // At minimum widths the offscreen webview cannot acknowledge inner focus until its owned tab is maximized. await waitUntil( () => { @@ -6627,7 +6656,7 @@ export class StudyWorkspace { * @example * ```ts * await focusedPane.endReadMaximization(async () => { - * await vscode.commands.executeCommand('workbench.action.minimizeOtherEditors') + * await this.executeEditorCommand('workbench.action.minimizeOtherEditors') * await this.showSideBarsHiddenByRead() * }) * ``` @@ -6688,7 +6717,9 @@ export class StudyWorkspace { this.sameStudyPanePositions(receipt.panes), ) const layoutBefore = await this.groupSizesBeforeEnlarge(before) - const assertRetained = this.retainPresentationPlacement() + const assertRetained = this.retainPresentationPlacement( + 'enlargePresentationPanel/assertRetained', + ) this.enlargedLayoutReceipt = undefined this.presentationConfirmed = false try { @@ -6703,7 +6734,7 @@ export class StudyWorkspace { ? await this.readPassivePresentationLayout() : undefined assertRetained() - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.toggleMaximizeEditorGroup', ) assertRetained() @@ -6720,12 +6751,16 @@ export class StudyWorkspace { } } - /** Protects passive awaits in {@link performPresentation} from adopting learner pane/group/focus changes. @param retainFocus - Compares the focused group, tab and text editor too. @param acceptWikiGroupReturn - Takes the editor's own selection of the Repository Wiki's group beside the identical text editor as no change ({@link isWikiGroupBesideTextEditor}); only the borrow receipt of {@link readMainEditorGrid} asks for it (finding 606). @example const assertRetained = this.retainPresentationPlacement(); */ + /** Protects passive awaits in {@link performPresentation} from adopting learner pane/group/focus changes. @param callSite - Short name of the caller and its guard (`method/variable`), printed in a refusal's report so a native take can tell which await refused. @param retainFocus - Compares the focused group, tab and text editor too. @param acceptWikiGroupReturn - Takes the editor's own selection of the Repository Wiki's group beside the identical text editor as no change ({@link isWikiGroupBesideTextEditor}); only the borrow receipt of {@link readMainEditorGrid} asks for it (finding 606). @example const assertRetained = this.retainPresentationPlacement('open/assertPlacement'); */ private retainPresentationPlacement( + callSite: string, retainFocus = true, acceptWikiGroupReturn = false, ): () => void { - const armed = this.armPlacementReport('retainPresentationPlacement') + const armed = this.armPlacementReport( + 'retainPresentationPlacement', + callSite, + ) const transition = this.presentationTransition, lifetime = this.lifetime const panes = this.studyPanePositions(), @@ -6807,32 +6842,35 @@ export class StudyWorkspace { (coveredBackgroundSource && registeringApp === this.app && focusedSource() === undefined) - const sameFocus = - !retainFocus || - (retainedFocus.group === - this.editorGroupIdentities.get( - vscode.window.tabGroups.activeTabGroup, - ) && - (focusedWebview === undefined || - (sameWebviewTab && - focusedWebviewCloseRevision === - this.webviewCloseRevisions.get(focusedWebview))) && - sameSource && - appActive === this.app?.active && - contextActive === this.contextPanel?.active) - if ( - this.sameEditorGroups(groups, vscode.window.tabGroups.all) && - this.sameStudyPanePositions(panes) && - sameFocus + // Each comparison is kept apart, so a refusal's report names the ones that failed. + const failedChecks = failedPlacementChecks( + { + groups: this.sameEditorGroups(groups, vscode.window.tabGroups.all), + panePositions: this.sameStudyPanePositions(panes), + focusGroup: + retainedFocus.group === + this.editorGroupIdentities.get( + vscode.window.tabGroups.activeTabGroup, + ), + webviewTab: focusedWebview === undefined || sameWebviewTab, + closeRevision: + focusedWebview === undefined || + focusedWebviewCloseRevision === + this.webviewCloseRevisions.get(focusedWebview), + source: sameSource, + appActive: appActive === this.app?.active, + contextActive: contextActive === this.contextPanel?.active, + }, + retainFocus, ) - return + if (!failedChecks.length) return // Retire only this operation's focus ownership; an older passive read cannot suppress a newer transition's recovery. if ( transition === this.presentationTransition && lifetime === this.lifetime ) this.yieldFocusToLearner() - throw this.refusePlacement(armed) + throw this.refusePlacement(armed, failedChecks) } } @@ -7003,8 +7041,8 @@ export class StudyWorkspace { this.initialAppPlacement = undefined } if (replacementContext) this.bindContextPanel(replacementContext) - if (replacementApp) previousApp.dispose() - if (replacementContext) previousContext.dispose() + if (replacementApp) this.disposePane(previousApp) + if (replacementContext) this.disposePane(previousContext) await waitUntil( () => { signal.throwIfAborted() @@ -7044,7 +7082,7 @@ export class StudyWorkspace { for (const subscription of subscriptions) subscription.dispose() // Failed creation removes only unadopted owned panes; adopted state never rolls back over newer learner input. for (const [region, replacement] of replacements) - if (this[region] !== replacement) replacement.dispose() + if (this[region] !== replacement) this.disposePane(replacement) } } @@ -7098,7 +7136,9 @@ export class StudyWorkspace { let assertPlacedSourceFocus: (() => void) | undefined if (!sourceUri) throw new Error('No selected immutable source is available.') - const assertSourcePlacement = this.retainPresentationPlacement() + const assertSourcePlacement = this.retainPresentationPlacement( + 'placePresentation/assertSourcePlacement', + ) const document = await vscode.workspace.openTextDocument(sourceUri) signal.throwIfAborted() assertEvidence() @@ -7202,7 +7242,9 @@ export class StudyWorkspace { } // Native debugger stops open prepared repository files as previews even while the captured reader is active, and a learner's single click opens a workspace file as one; both stay, pinned. for (const prepared of preparedPreviews) { - const assertPreparedPlacement = this.retainPresentationPlacement() + const assertPreparedPlacement = this.retainPresentationPlacement( + 'placePresentation/assertPreparedPlacement', + ) const sourceDocument = await Promise.resolve( vscode.workspace.openTextDocument(prepared.uri), ).catch(() => { @@ -7220,7 +7262,7 @@ export class StudyWorkspace { throw new Error( 'The prepared preview changed before it could be retained.', ) - await vscode.window.showTextDocument(sourceDocument, { + await this.showTextDocumentNoted(sourceDocument, { viewColumn: prepared.group.viewColumn, preserveFocus: true, preview: false, @@ -7242,8 +7284,10 @@ export class StudyWorkspace { } assertPreviewOwnership() // Native relocation can flatten the nested grid and omit its height; retain both axes before moving panes. - const assertBeforeMove = this.retainPresentationPlacement() - const retainedLayout = await vscode.commands.executeCommand( + const assertBeforeMove = this.retainPresentationPlacement( + 'placePresentation/assertBeforeMove', + ) + const retainedLayout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertPlacement() @@ -7270,7 +7314,7 @@ export class StudyWorkspace { requestedSourcePosition, requestedSourcePosition, ) - const editor = await vscode.window.showTextDocument(document, { + const editor = await this.showTextDocumentNoted(document, { viewColumn: descriptor.sourceColumn, preserveFocus: false, preview: false, @@ -7337,7 +7381,7 @@ export class StudyWorkspace { ownedContext.viewColumn !== descriptor.contextColumn ) { // Move only the owned explanation after source occupies column two, so its new group cannot close on creation. - ownedContext.reveal(descriptor.contextColumn, true) + this.revealPane(ownedContext, descriptor.contextColumn, true) await waitUntil( () => { assertPlacement() @@ -7392,15 +7436,17 @@ export class StudyWorkspace { this.sourceGroup = vscode.window.tabGroups.all.find( (group) => group.viewColumn === descriptor.sourceColumn, ) - const assertAfterMove = this.retainPresentationPlacement() - const measuredLayout = await vscode.commands.executeCommand( + const assertAfterMove = this.retainPresentationPlacement( + 'placePresentation/assertAfterMove', + ) + const measuredLayout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertPlacement() assertAfterMove() assertSourceFocus() // Minimum-size native groups may opt out of proportional expansion; supply actual pixels when both axes are measured. - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'vscode.setEditorLayout', toPixelEditorLayout(descriptor.layout, measuredLayout) ?? toPixelEditorLayout(descriptor.layout, retainedLayout) ?? @@ -7408,10 +7454,12 @@ export class StudyWorkspace { ) assertPlacement() assertSourceFocus() - const assertSettling = this.retainPresentationPlacement() + const assertSettling = this.retainPresentationPlacement( + 'placePresentation/assertSettling', + ) await waitUntil( async () => { - const actual = await vscode.commands.executeCommand( + const actual = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertPlacement() @@ -7425,14 +7473,16 @@ export class StudyWorkspace { ) assertPlacement() assertSourceFocus() - const assertConfirmed = this.retainPresentationPlacement() + const assertConfirmed = this.retainPresentationPlacement( + 'placePresentation/assertConfirmed', + ) // Relocation and resizing can replace the native view state; restore a visible anchor only after both settle. if (!app.active && !context.active) assertSourceFocus() await restoreReadingPosition(editor, signal) assertPlacement() assertConfirmed() if (!app.active && !context.active) assertSourceFocus() - const layout = await vscode.commands.executeCommand( + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertPlacement() @@ -7650,10 +7700,10 @@ export class StudyWorkspace { })), } grid.assertUnchanged() - await vscode.commands.executeCommand('vscode.setEditorLayout', layout) + await this.executeEditorCommand('vscode.setEditorLayout', layout) assertColdCurrent() grid.assertFocused() - const actual = await vscode.commands.executeCommand( + const actual = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertColdCurrent() @@ -8221,7 +8271,7 @@ export class StudyWorkspace { if (this.liveComposition || this.summaryComposition) { // Live uses the host chat; disposing only our panel never closes another editor's tabs. const disposedStudyPane = this.contextPanel - this.contextPanel?.dispose() + this.disposePane(this.contextPanel) this.contextPanel = undefined this.presentationMode = 'study-workspace' // A Study pane in a learner's group leaves no retired group for the arrangement to drain, yet the editor reports the group's next tab active before it closes the disposed tab; a guard armed meanwhile reads that as the learner's focus change (N55-F4 refusal 2). Every guard therefore starts after the close. @@ -8331,7 +8381,8 @@ export class StudyWorkspace { this.app?.viewColumn !== STUDY_PRESENTATIONS[this.presentationMode].appColumn ) { - this.app?.reveal( + this.revealPane( + this.app, STUDY_PRESENTATIONS[this.presentationMode].appColumn, true, ) @@ -8405,7 +8456,7 @@ export class StudyWorkspace { historyRequest === this.historyRequest ) { const app = this.app - app?.reveal(undefined, true) + this.revealPane(app, undefined, true) // The editor reports the reveal a moment later; the arrangement below would read that report as a change the learner made, so it waits, bounded, until the pane is the tab its group shows. if (app) await waitUntil( @@ -8434,9 +8485,9 @@ export class StudyWorkspace { // Reentry without a retained layout cannot arrange anything; already manual groups must remain usable. const assertPlacement = firstOpen || this.layoutReceipt - ? this.retainPresentationPlacement(false) + ? this.retainPresentationPlacement('open/assertPlacement', false) : (): void => {} - panel.reveal(undefined, false) + this.revealPane(panel, undefined, false) // Drain the requested native focus change before the final layout read starts a passive ownership guard. await waitUntil( async () => { @@ -8459,7 +8510,7 @@ export class StudyWorkspace { ) return true // A side chat can retain focus while the Context tab is visibly placed in the only editor part. - const layout = await vscode.commands.executeCommand( + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(lifetime) @@ -8590,7 +8641,7 @@ export class StudyWorkspace { // The refusal leaves the window as the entry found it. The pane is released first: its close handler would read the disposal as the learner closing the pane and cancel the recording load whose refusal is being answered. if (unarrangedApp && unarrangedApp === this.app) { this.app = undefined - unarrangedApp.dispose() + this.disposePane(unarrangedApp) } // A newer focus of the learner stays, also the one the refusal names; the keyboard a measurement borrowed goes back to the editor part that held it, told that nothing was arranged (rows 718, 731 and 736). if ( @@ -8942,7 +8993,7 @@ export class StudyWorkspace { if (this.summaryComposition) await this.showLearningSummary() // A Study composition without its pane (closed, or lost to an earlier Live composition) gets it back (F208). else if (!this.contextPanel && !this.liveComposition) await this.open() - else this.contextPanel?.reveal(undefined, false) + else this.revealPane(this.contextPanel, undefined, false) } if (action === 'read-app') await this.readStudyPane('app') if (action === 'read-source') await this.readStudyPane('source') @@ -8958,7 +9009,7 @@ export class StudyWorkspace { await this.enlargePresentationPanel(this.contextPanel) } if (action === 'outline') - await vscode.commands.executeCommand('codebook.learningOutline.focus') + await this.executeEditorCommand('codebook.learningOutline.focus') // One click replaces opening Run and Debug, expanding Variables and expanding its first scope by hand. if (action === 'show-variables') { this.isShowingNativeVariables = true @@ -9022,7 +9073,7 @@ export class StudyWorkspace { 'Current file differs from the selected captured source. The captured document remains unchanged.', ), ) - await vscode.window.showTextDocument(document, { + await this.showTextDocumentNoted(document, { viewColumn: vscode.ViewColumn.Beside, preview: false, }) @@ -9267,7 +9318,9 @@ export class StudyWorkspace { chatCause?: ChatKeyboardReturnCause, yieldedToLearner = false, ): Promise { - const assertRetainedFocus = this.retainPresentationPlacement(), + const assertRetainedFocus = this.retainPresentationPlacement( + 'finishCurrentReturn/assertRetainedFocus', + ), selections = JSON.stringify(vscode.window.activeTextEditor?.selections) // Native command discovery can wait; recheck ownership immediately before its eventual focus dispatch. const stillCurrent = (): boolean => { @@ -9347,16 +9400,17 @@ export class StudyWorkspace { place: ReturnType, ): Promise { if (place === 'moments' && this.moments?.visible) { - await vscode.commands - .executeCommand('codebook.observedMoments.focus') - .then(undefined, () => undefined) + await this.executeEditorCommand('codebook.observedMoments.focus').then( + undefined, + () => undefined, + ) return } // A Study pane the Return did not close keeps its keyboard. if (place !== 'study' || this.contextPanel) return - await vscode.commands - .executeCommand('workbench.action.focusActiveEditorGroup') - .then(undefined, () => undefined) + await this.executeEditorCommand( + 'workbench.action.focusActiveEditorGroup', + ).then(undefined, () => undefined) this.tellLearnersReturnKeyboard() } @@ -9633,9 +9687,9 @@ export class StudyWorkspace { // A closed reader has no live or replay arrangement: the next selection opens it instead of arranging panes that no longer exist. this.liveComposition = false this.summaryComposition = false - contextPanel?.dispose() + this.disposePane(contextPanel) this.contextPanel = undefined - applicationPanel?.dispose() + this.disposePane(applicationPanel) this.app = undefined this.initialAppPlacement = undefined // A refused or late close of the Application's group is told and ends nothing: the cleanup below still runs (Cycle 0046 OCR1-4). @@ -9831,9 +9885,9 @@ export class StudyWorkspace { */ private async namesOnlyMainEditorGroups(): Promise { if (this.disposed || !vscode.window.state.focused) return false - const layout = await vscode.commands - .executeCommand('vscode.getEditorLayout') - .then(undefined, () => undefined) + const layout = await this.executeEditorCommand( + 'vscode.getEditorLayout', + ).then(undefined, () => undefined) return editorLayoutGroupCount(layout) === vscode.window.tabGroups.all.length } @@ -9916,7 +9970,7 @@ export class StudyWorkspace { ) ) return - panel.dispose() + this.disposePane(panel) // A refused or late group close is told and keeps the keyboard's settle below. await this.releaseApplicationGroup().catch((error: unknown) => this.report('layout', error), @@ -10014,9 +10068,9 @@ export class StudyWorkspace { await this.closedPanesGone('codebook.scopedBrowser') // Native tab removal can outlast the learner leaving this window; the earlier focus sample no longer owns its keyboard. if (this.app || this.closing || !vscode.window.state.focused) return - await vscode.commands - .executeCommand('workbench.action.focusActiveEditorGroup') - .then(undefined, () => undefined) + await this.executeEditorCommand( + 'workbench.action.focusActiveEditorGroup', + ).then(undefined, () => undefined) this.tellKeyboardAfterRunClosedPane() } @@ -10182,9 +10236,9 @@ export class StudyWorkspace { */ private async giveActiveGroupKeyboardAfterClose(): Promise { await this.closedPanesGone() - await vscode.commands - .executeCommand('workbench.action.focusActiveEditorGroup') - .then(undefined, () => undefined) + await this.executeEditorCommand( + 'workbench.action.focusActiveEditorGroup', + ).then(undefined, () => undefined) this.tellKeyboardAfterClose() } @@ -10388,9 +10442,10 @@ export class StudyWorkspace { // Keep the final group/layout decision and its command in the same continuation. const layout = this.layoutOfBeforeOpenToRestore(before, now, groups) if (layout === undefined) return - await vscode.commands - .executeCommand('vscode.setEditorLayout', layout) - .then(undefined, () => undefined) + await this.executeEditorCommand('vscode.setEditorLayout', layout).then( + undefined, + () => undefined, + ) } /** @@ -11722,7 +11777,7 @@ export class StudyWorkspace { return 'retry' } if (tab.input instanceof vscode.TabInputText) { - await vscode.window.showTextDocument(tab.input.uri, { + await this.showTextDocumentNoted(tab.input.uri, { viewColumn: obstacle.viewColumn, preview: false, preserveFocus: true, @@ -11759,11 +11814,11 @@ export class StudyWorkspace { ): Promise<'retry' | 'told'> { const focusCommand = focusEditorGroupCommand(group.viewColumn) if (focusCommand && group.activeTab === tab) { - await vscode.commands.executeCommand(focusCommand) + await this.executeEditorCommand(focusCommand) // The command keeps the tab that is in front of the group the focus moved to; the tab is re-read after it. const focused = vscode.window.tabGroups.activeTabGroup if (focused.viewColumn === group.viewColumn && focused.activeTab === tab) - await vscode.commands.executeCommand('workbench.action.keepEditor') + await this.executeEditorCommand('workbench.action.keepEditor') } const stillPreview = vscode.window.tabGroups.all .filter((candidate) => candidate.viewColumn === group.viewColumn) @@ -14206,7 +14261,7 @@ export class StudyWorkspace { ) // Same-input updates do not reveal a hidden editor area; an owned recorded seek restores the panel explicitly. if (this.recordingActive && confirmedLayout) { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.toggleMaximizedPanel', ) this.assertLifetime(lifetime) @@ -14248,7 +14303,7 @@ export class StudyWorkspace { // Updating an existing input neither replaces its DOM editor nor guesses whether source, chat or a view owns the keyboard. const editor = existingView ?? - (await vscode.window.showTextDocument(document, { + (await this.showTextDocumentNoted(document, { viewColumn: targetColumn, preserveFocus: preserveFocus && !takesPausedFocus, preview: false, @@ -14564,12 +14619,13 @@ export class StudyWorkspace { document: vscode.TextDocument, column: vscode.ViewColumn, ): Promise { - await vscode.window - .showTextDocument(document, { viewColumn: column, preserveFocus: false }) - .then( - () => undefined, - () => undefined, - ) + await this.showTextDocumentNoted(document, { + viewColumn: column, + preserveFocus: false, + }).then( + () => undefined, + () => undefined, + ) } /** @@ -14800,12 +14856,12 @@ export class StudyWorkspace { if ( reapplication === 'when-collapsed' && !this.isApplicationGroupCollapsed( - await vscode.commands.executeCommand('vscode.getEditorLayout'), + await this.executeEditorCommand('vscode.getEditorLayout'), receipt, ) ) return - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'vscode.setEditorLayout', JSON.parse(receipt.signature), ) @@ -14964,13 +15020,11 @@ export class StudyWorkspace { }): void { this.stopHandOver.markDone(paused.sessionId) // A keyboard courtesy: an editor that refuses the show leaves the focus where it was. - void vscode.window - .showTextDocument(paused.editor.document, { - viewColumn: paused.editor.viewColumn, - preserveFocus: false, - selection: paused.editor.selection, - }) - .then(undefined, () => undefined) + void this.showTextDocumentNoted(paused.editor.document, { + viewColumn: paused.editor.viewColumn, + preserveFocus: false, + selection: paused.editor.selection, + }).then(undefined, () => undefined) } /** @@ -15290,7 +15344,7 @@ export class StudyWorkspace { await this.prepareApplicationGroup(undefined, undefined, true) await this.open() } - this.app?.reveal(undefined, false) + this.revealPane(this.app, undefined, false) } /** @@ -15777,7 +15831,7 @@ export class StudyWorkspace { this.applicationGroupChange = { kind: 'insert' } try { sourceFocus.assertRetained() - await vscode.commands.executeCommand('workbench.action.newGroupAbove') + await this.executeEditorCommand('workbench.action.newGroupAbove') } finally { sourceFocus.dispose() this.applicationGroupChange = undefined @@ -15816,8 +15870,10 @@ export class StudyWorkspace { ), assertCurrent, ) - const assertPlaced = this.retainPresentationPlacement() - let layout = await vscode.commands.executeCommand( + const assertPlaced = this.retainPresentationPlacement( + 'reserveApplicationGroup/assertPlaced', + ) + let layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) // Retain accepted native placement for a later Return even when this entry was cancelled; Close and newer input still revoke its proof. @@ -16433,7 +16489,7 @@ export class StudyWorkspace { const allocated = this.applicationGroup?.identity // Only the pane this preparation created goes; a pane the reader adopted meanwhile stays. if (this.app === createdPanel) this.app = undefined - createdPanel.dispose() + this.disposePane(createdPanel) await this.closedPanesGone('codebook.scopedBrowser') await this.retireFailedApplicationPreparation(allocated, undefined) } @@ -16457,7 +16513,7 @@ export class StudyWorkspace { this.app = undefined this.applicationPreparationSource = undefined this.replayApplicationCovered = false - createdPanel.dispose() + this.disposePane(createdPanel) await this.closedPanesGone('codebook.scopedBrowser') } // No allocation means no cleanup authority; two unknown identities never prove ownership. @@ -16647,7 +16703,7 @@ export class StudyWorkspace { : undefined if (expectedRemoval) this.applicationGroupChange = expectedRemoval try { - panel.reveal(vscode.ViewColumn.One, true) + this.revealPane(panel, vscode.ViewColumn.One, true) // The editor reports the move later; a placement guard taken before that report reads this own move as a learner change. await waitUntil( () => panel.viewColumn === vscode.ViewColumn.One, @@ -16882,9 +16938,9 @@ export class StudyWorkspace { orientation: VERTICAL_EDITOR_ORIENTATION, groups: Array.from({ length: count }, () => ({})), } - await vscode.commands.executeCommand('vscode.setEditorLayout', stacked) + await this.executeEditorCommand('vscode.setEditorLayout', stacked) assertPlaced() - const read = await vscode.commands.executeCommand( + const read = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertPlaced() @@ -16908,10 +16964,10 @@ export class StudyWorkspace { ): Promise { if (!hasRowAtMinimumHeight(layout)) return layout const rows = stackRowsAtOneHeight(layout) - await vscode.commands.executeCommand('vscode.setEditorLayout', rows) + await this.executeEditorCommand('vscode.setEditorLayout', rows) this.assertLifetime(lifetime) assertPlaced() - const read = await vscode.commands.executeCommand( + const read = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(lifetime) @@ -16980,7 +17036,7 @@ export class StudyWorkspace { } }) try { - await vscode.commands.executeCommand('workbench.action.lockEditorGroup') + await this.executeEditorCommand('workbench.action.lockEditorGroup') } finally { listener.dispose() } @@ -17010,9 +17066,7 @@ export class StudyWorkspace { lockReport.identity, assertCurrent, ) - await vscode.commands.executeCommand( - 'workbench.action.focusFirstEditorGroup', - ) + await this.executeEditorCommand('workbench.action.focusFirstEditorGroup') assertCurrent() if ( this.editorGroupIdentities.get(vscode.window.tabGroups.activeTabGroup) !== @@ -17021,7 +17075,7 @@ export class StudyWorkspace { throw new Error( 'Native focus changed before the new Application group could be locked.', ) - await vscode.commands.executeCommand('workbench.action.lockEditorGroup') + await this.executeEditorCommand('workbench.action.lockEditorGroup') assertCurrent() return true } @@ -17049,11 +17103,11 @@ export class StudyWorkspace { if (!isActive()) { const focusCommand = focusEditorGroupCommand(group.viewColumn) if (!focusCommand) return - await vscode.commands.executeCommand(focusCommand) + await this.executeEditorCommand(focusCommand) assertCurrent() if (!isActive()) return } - await vscode.commands.executeCommand('workbench.action.unlockEditorGroup') + await this.executeEditorCommand('workbench.action.unlockEditorGroup') } /** @@ -17460,7 +17514,7 @@ export class StudyWorkspace { this.textTabUri(group.activeTab) !== editor.document.uri.toString() ) return false - await vscode.commands.executeCommand(command).then( + await this.executeEditorCommand(command).then( () => undefined, () => undefined, ) @@ -17547,17 +17601,17 @@ export class StudyWorkspace { ): Promise<{ groupCount: number; layout: unknown } | undefined> { const groupsBefore = vscode.window.tabGroups.all.length // Layout reads answer for the part that has the keyboard (a focused chat part answers for itself), and the editor unlocks only the active group: a group at the record's column that is not yet that group takes the keyboard before the proof. - if (focusCommand) await vscode.commands.executeCommand(focusCommand) + if (focusCommand) await this.executeEditorCommand(focusCommand) assertCurrent() if (!(await this.provenLeftoverGroup(marker))) return undefined - await vscode.commands.executeCommand('workbench.action.unlockEditorGroup') + await this.executeEditorCommand('workbench.action.unlockEditorGroup') const group = await this.provenLeftoverGroup(marker) if (!group) throw new Error( 'The editor refused to retire the empty Application group an earlier window left.', ) await this.closeLeftoverAsIssuedRemoval(group, groupsBefore, signal) - const layout = await vscode.commands.executeCommand( + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) if (editorLayoutGroupCount(layout) !== marker.groupCount - 1) @@ -17611,7 +17665,7 @@ export class StudyWorkspace { private async provenLeftoverGroup( marker: Parameters[0], ): Promise { - const layout = await vscode.commands.executeCommand( + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) const group = vscode.window.tabGroups.all.find( @@ -17737,7 +17791,10 @@ export class StudyWorkspace { initialGroup = this.editorGroupIdentities.get( vscode.window.tabGroups.activeTabGroup, ), - assertPlacement = this.retainPresentationPlacement(false), + assertPlacement = this.retainPresentationPlacement( + 'retireSourceDerivedGroup/assertPlacement', + false, + ), copiedDocument = currentSource?.document let inputChanged = false /** @@ -17770,7 +17827,7 @@ export class StudyWorkspace { if (sourceToCopy) { const version = sourceToCopy.document.version, selections = sourceToCopy.selections, - shown = await vscode.window.showTextDocument(sourceToCopy.document, { + shown = await this.showTextDocumentNoted(sourceToCopy.document, { viewColumn: original.viewColumn, preserveFocus: initialEditor !== sourceToCopy, preview: false, @@ -17976,7 +18033,7 @@ export class StudyWorkspace { if (takesKeyboard) { this.applicationFocusBorrows++ this.firstGroupFrontAtBorrow = undefined - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.focusFirstEditorGroup', ) // What the borrow left in front of the first group, for a refused change's give-back (row 728). @@ -17993,7 +18050,10 @@ export class StudyWorkspace { borrow ) { const { retained, sourceTab } = borrow - const assertGroups = this.retainPresentationPlacement(false), + const assertGroups = this.retainPresentationPlacement( + 'focusApplicationSourceGroup/assertGroups', + false, + ), version = retained.document.version, selections = JSON.stringify(retained.selections), retainedUri = retained.document.uri.toString() @@ -18017,14 +18077,11 @@ export class StudyWorkspace { }), ] try { - const shown = await vscode.window.showTextDocument( - retained.document, - { - viewColumn: vscode.ViewColumn.One, - preserveFocus: false, - preview: sourceTab.isPreview, - }, - ) + const shown = await this.showTextDocumentNoted(retained.document, { + viewColumn: vscode.ViewColumn.One, + preserveFocus: false, + preview: sourceTab.isPreview, + }) assertCurrent() assertGroups() this.assertBorrowedPreparationSource(retained, shown, { @@ -18044,7 +18101,10 @@ export class StudyWorkspace { throw new Error( 'Focus the first main source group before preparing the Application.', ) - const assertPlaced = this.retainPresentationPlacement(!sourceReceipt) + const assertPlaced = this.retainPresentationPlacement( + 'focusApplicationSourceGroup/assertPlaced', + !sourceReceipt, + ) return { assertRetained: (): void => { try { @@ -18217,7 +18277,10 @@ export class StudyWorkspace { namedIdentity = this.editorGroupIdentities.get(namedGroup), namedTab = namedGroup.activeTab, initialEditor = vscode.window.activeTextEditor, - assertGroups = this.retainPresentationPlacement(false), + assertGroups = this.retainPresentationPlacement( + 'retainPreparationPrimaryInput/assertGroups', + false, + ), primaryTabKey = this.readingTabKey(primary, primary.activeTab), primaryInput = JSON.stringify(primary.activeTab?.input), namedTabKey = this.readingTabKey(namedGroup, namedTab), @@ -18651,7 +18714,8 @@ export class StudyWorkspace { try { this.applicationGroup = parked // Moving the keyboard-owning App must carry its input out of the emptied group; an inactive App preserves the current input. - if (!owned.parked) panel.reveal(destination.viewColumn, !panel.active) + if (!owned.parked) + this.revealPane(panel, destination.viewColumn, !panel.active) await waitUntil( () => { if (this.app !== panel) @@ -19284,6 +19348,7 @@ export class StudyWorkspace { }) let visible = panel.visible panel.onDidChangeViewState(() => { + this.notePlacementViewState('Application', panel) if (this.app !== panel) return // The pane's active flag is one of the readings of the chat's record, and the editor reports it on this channel. this.sampleKeyboardOutsideEditors() @@ -19467,6 +19532,7 @@ export class StudyWorkspace { this.clearPendingContextCreation() }) panel.onDidChangeViewState(() => { + this.notePlacementViewState('Study', panel) if (this.contextPanel !== panel) return this.bindPendingContextCreation() // The pane's active flag is one of the readings of the chat's record, and the editor reports it on this channel. @@ -19539,7 +19605,7 @@ export class StudyWorkspace { const focusOptions = preserveFocus ? { preserveFocus: true } : undefined // Only explicit replay entry reveals the bottom controls; passive ticks never reopen a closed panel, and an agent's entry switches no panel container. if (this.revealsObservedMoments(revealPlayback, keepsViewsInFront)) { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'codebook.observedMoments.focus', focusOptions, ) @@ -19548,7 +19614,7 @@ export class StudyWorkspace { const keptBehind = this.keepsSummaryBehindSideBarFront(keepsViewsInFront) // Only a summary that may come in front of the chat's bar stays where it is; any other is brought to the front and awaited. if (!keptBehind) { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'codebook.learningSummary.focus', focusOptions, ) @@ -19651,7 +19717,7 @@ export class StudyWorkspace { // A flag that did not persist would repeat the move at every open: no move without it. if (state.get(SUMMARY_BESIDE_CHAT_MOVED_KEY) !== true) throw new Error('The profile flag of the summary move was not saved.') - await vscode.commands.executeCommand( + await this.executeEditorCommand( MOVE_VIEWS_COMMAND, SUMMARY_BESIDE_CHAT_MOVE_ARGUMENT, ) @@ -19720,7 +19786,7 @@ export class StudyWorkspace { true, ) // The editor's Move View list opens for the summary itself; the learner picks its destination there. - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.moveFocusedView', 'codebook.learningSummary', ) @@ -19751,7 +19817,7 @@ export class StudyWorkspace { SHOW_NATIVE_VARIABLES_COMMANDS if (hostCommands.has(focusVariablesCommand)) { const wasSummaryVisible = Boolean(this.summaryView?.visible) - await vscode.commands.executeCommand(focusVariablesCommand) + await this.executeEditorCommand(focusVariablesCommand) // In Cursor the summary shares its side bar with Run and Debug, which the focus command just put in its place; the learner is told how to keep both. if (wasSummaryVisible && !this.summaryView?.visible) this.offerSummaryPlacement('summary-displaced') @@ -19774,10 +19840,10 @@ export class StudyWorkspace { // A reader closed during the wait must not move the focus of whatever list the learner went on to. if (valuesListed || this.closing || this.disposed) return // The list commands act on whichever list holds the focus, and the learner may have clicked into another one during the waits: Variables gets the focus back first. - await vscode.commands.executeCommand(focusVariablesCommand) + await this.executeEditorCommand(focusVariablesCommand) for (const command of listCommands) if (hostCommands.has(command)) - await vscode.commands.executeCommand(command) + await this.executeEditorCommand(command) // Without a historical session that can report the values, a second attempt has nothing to wait for. if (!debuggerView?.available) return } @@ -19785,7 +19851,7 @@ export class StudyWorkspace { } // Run and Debug is the nearest place this host can open; the reader's hint names the step that remains. if (hostCommands.has(NATIVE_RUN_AND_DEBUG_VIEW_COMMAND)) { - await vscode.commands.executeCommand(NATIVE_RUN_AND_DEBUG_VIEW_COMMAND) + await this.executeEditorCommand(NATIVE_RUN_AND_DEBUG_VIEW_COMMAND) return } // Nothing to run: one sentence instead of a failed command. @@ -19816,9 +19882,9 @@ export class StudyWorkspace { ) return this.summaryHeldFocus = false - void vscode.commands - .executeCommand('workbench.action.focusActiveEditorGroup') - .then(undefined, (error) => this.report('reader', error)) + void this.executeEditorCommand( + 'workbench.action.focusActiveEditorGroup', + ).then(undefined, (error) => this.report('reader', error)) }, SUMMARY_FOCUS_RELEASE_WAIT_MS) } @@ -19867,9 +19933,11 @@ export class StudyWorkspace { if (this.outline) return // Cursor removes an information notification with a button after about 15 s; the title action and the palette entry stay. if (this.isCursorHost()) - void vscode.commands - .executeCommand('setContext', SUMMARY_PLACEMENT_CONTEXT_KEY, true) - .then(undefined, (error: unknown) => this.report('layout', error)) + void this.executeEditorCommand( + 'setContext', + SUMMARY_PLACEMENT_CONTEXT_KEY, + true, + ).then(undefined, (error: unknown) => this.report('layout', error)) this.observeEditorGroups() this.watchKeyboardOutsideEditors() this.disposables.push( @@ -19883,6 +19951,7 @@ export class StudyWorkspace { ) this.disposables.push( vscode.window.tabGroups.onDidChangeTabs((event) => { + this.notePlacementTabEvent(event) // Real closure ends every existing focus receipt for that type; wrapper refresh alone does not close a tab. for (const tab of event.closed) if (tab.input instanceof vscode.TabInputWebview) { @@ -20265,8 +20334,10 @@ export class StudyWorkspace { private async readPassivePresentationLayout(): Promise { // The Study's group count bounds the focus before the read; the measured count, after it, decides another part (row 716). const mainFocused = this.hasFocusWithinColumns(STUDY_EDITOR_GROUP_COUNT), - assertRetained = this.retainPresentationPlacement() - const layout = await vscode.commands.executeCommand( + assertRetained = this.retainPresentationPlacement( + 'readPassivePresentationLayout/assertRetained', + ) + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ), count = editorLayoutGroupCount(layout) @@ -20339,12 +20410,12 @@ export class StudyWorkspace { : undefined // A text editor in front of its group is shown again as before. if (!command) - return vscode.window.showTextDocument(editor.document, { + return this.showTextDocumentNoted(editor.document, { viewColumn: column, preserveFocus: false, preview, }) - await vscode.commands.executeCommand(command) + await this.executeEditorCommand(command) await waitUntil( () => editorShowsDocument( @@ -20397,7 +20468,7 @@ export class StudyWorkspace { matchesGroup: () => vscode.window.tabGroups.activeTabGroup.viewColumn === column, }) - await vscode.commands.executeCommand(command) + await this.executeEditorCommand(command) await waitUntil( editorFocused, "the floating window's editor focus", @@ -20472,7 +20543,9 @@ export class StudyWorkspace { beforeScopeFocus?: (acquire?: RepositoryFocusTarget) => void, ) { let assertAcquiredFocus = (): void => {} - const assertInitial = this.retainPresentationPlacement() + const assertInitial = this.retainPresentationPlacement( + 'readMainEditorGrid/assertInitial', + ) const initialTab = vscode.window.tabGroups.activeTabGroup.activeTab const initialWebview = initialTab?.input instanceof vscode.TabInputWebview @@ -20508,7 +20581,7 @@ export class StudyWorkspace { vscode.window.onDidChangeActiveTextEditor(retainInitial), ] try { - layout = await vscode.commands.executeCommand( + layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertCurrent() @@ -20550,7 +20623,10 @@ export class StudyWorkspace { const preview = vscode.window.tabGroups.activeTabGroup.activeTab?.isPreview ?? false const restoreReading = retainEditorReadingPosition(editor), - assertGroups = this.retainPresentationPlacement(false) + assertGroups = this.retainPresentationPlacement( + 'readMainEditorGrid/assertGroups', + false, + ) const firstInput = vscode.window.tabGroups.all.find( (group) => group.viewColumn === vscode.ViewColumn.One, )?.activeTab?.input @@ -20723,7 +20799,7 @@ export class StudyWorkspace { assertGroups() assertAcquiredFocus() } else if (sourceBorrow) { - await vscode.window.showTextDocument(sourceBorrow.document, { + await this.showTextDocumentNoted(sourceBorrow.document, { viewColumn: sourceBorrow.viewColumn, preserveFocus: false, preview: false, @@ -20731,7 +20807,7 @@ export class StudyWorkspace { assertCurrent() assertGroups() } else { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'workbench.action.focusFirstEditorGroup', ) assertCurrent() @@ -20796,8 +20872,12 @@ export class StudyWorkspace { assertCurrent() assertGroups() // The editor's own late return to the Repository Wiki's group beside the borrowed text editor is no learner change (finding 606: right after Study Close). - const assertFocused = this.retainPresentationPlacement(true, true) - layout = await vscode.commands.executeCommand( + const assertFocused = this.retainPresentationPlacement( + 'readMainEditorGrid/assertFocused', + true, + true, + ) + layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) assertCurrent() @@ -20844,7 +20924,7 @@ export class StudyWorkspace { ) }, }) - panel.reveal(undefined, false) + this.revealPane(panel, undefined, false) await waitUntil( () => panel.active && @@ -20981,7 +21061,9 @@ export class StudyWorkspace { ), ) const outsideContents = contents(outside), - assertPlacement = this.retainPresentationPlacement() + assertPlacement = this.retainPresentationPlacement( + 'readMainEditorGrid/assertPlacement', + ) const assertUnchanged = (): void => { assertPlacement() assertAcquiredFocus() @@ -21282,7 +21364,7 @@ export class StudyWorkspace { grid.groups.length < restoredGroupCount && grid.groups.length < vscode.window.tabGroups.all.length ) { - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'vscode.setEditorLayout', targetLayout, ) @@ -21293,7 +21375,7 @@ export class StudyWorkspace { column: group.viewColumn, })), panes = this.studyPanePositions() - const preparedLayout = await vscode.commands.executeCommand( + const preparedLayout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(lifetime) @@ -21327,14 +21409,15 @@ export class StudyWorkspace { return } if (restored.before.appColumn !== restored.after.appColumn) - this.app?.reveal(restored.before.appColumn, true) + this.revealPane(this.app, restored.before.appColumn, true) if (restored.before.contextColumn !== restored.after.contextColumn) - this.contextPanel?.reveal(restored.before.contextColumn, true) + this.revealPane( + this.contextPanel, + restored.before.contextColumn, + true, + ) // Restore a previously verified grid; arbitrary post-reveal geometry cannot establish Codebook ownership. - await vscode.commands.executeCommand( - 'vscode.setEditorLayout', - targetLayout, - ) + await this.executeEditorCommand('vscode.setEditorLayout', targetLayout) noteContextReservation() this.assertLifetime(lifetime) const preparedGroups = vscode.window.tabGroups.all.map((group) => ({ @@ -21346,7 +21429,7 @@ export class StudyWorkspace { sourceGroup: restored.after.sourceGroup, sourceColumn: restored.after.sourceColumn, } - const preparedLayout = await vscode.commands.executeCommand( + const preparedLayout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(lifetime) @@ -21394,15 +21477,15 @@ export class StudyWorkspace { ) throw new Error('The retained pane restoration changed before placement.') if (targets.appColumn !== receipt.panes.appColumn) - this.app?.reveal(targets.appColumn, true) + this.revealPane(this.app, targets.appColumn, true) if (targets.contextColumn !== receipt.panes.contextColumn) - this.contextPanel?.reveal(targets.contextColumn, true) + this.revealPane(this.contextPanel, targets.contextColumn, true) const expected = { ...receipt.panes, appColumn: targets.appColumn, contextColumn: targets.contextColumn, } - const layout = await vscode.commands.executeCommand( + const layout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(lifetime) @@ -21537,13 +21620,13 @@ export class StudyWorkspace { return } this.layoutReceipt = undefined - await vscode.commands.executeCommand( + await this.executeEditorCommand( 'vscode.setEditorLayout', saved.layout, ) // Retain accepted placement effects even after cancellation, so a newer evidence visit can reverse them safely. const after = this.studyPanePositions() - const restoredLayout = await vscode.commands.executeCommand( + const restoredLayout = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(queuedLifetime) @@ -22042,7 +22125,7 @@ export class StudyWorkspace { return } // The workbench accepts both native effects together; its interim split sizes are not a receipt. - await vscode.commands.executeCommand('runCommands', { + await this.executeEditorCommand('runCommands', { commands: [ { command: 'vscode.setEditorLayout', @@ -22179,7 +22262,7 @@ export class StudyWorkspace { let stableMismatch: { signature: string; sinceMs: number } | undefined const settled = await waitUntil( async () => { - const actual = await vscode.commands.executeCommand( + const actual = await this.executeEditorCommand( 'vscode.getEditorLayout', ) this.assertLifetime(lifetime) @@ -22270,27 +22353,62 @@ export class StudyWorkspace { const pixels = toPixelEditorLayout(this.studyLayout(), actual) // A reply captured in the main editor does not authorize a mutation in a chat part that gained focus while it waited. if (!pixels || !this.isMainEditorLayout(actual)) return false - await vscode.commands.executeCommand('vscode.setEditorLayout', pixels) + await this.executeEditorCommand('vscode.setEditorLayout', pixels) return true } } /** - * Notes the moment and the editor groups a placement guard starts waiting at, so {@link refusePlacement} can say what changed; called by each of the three placement guards when it is armed. + * Notes the moment and the editor groups a placement guard starts waiting at, so {@link refusePlacement} can say what changed; called by each of the placement guards when it is armed. * The groups are kept raw and cheap (labels are not sanitized here): a guard is armed far more often than it refuses, and {@link refusePlacement} sanitizes the snapshot only when it reports. * @param guard - The guard's name, as the report names it. - * @returns The arm time and the groups then, with raw labels and bounded. - * @example const armed = this.armPlacementReport('retainPresentationPlacement') + * @param callSite - The caller's short name, when the guard has several callers ({@link retainPresentationPlacement}). + * @returns The arm time, the groups then and the tab that had the focus, with raw labels and bounded. + * @example const armed = this.armPlacementReport('retainPresentationPlacement', 'open/assertPlacement') */ - private armPlacementReport(guard: string): { + private armPlacementReport( + guard: string, + callSite?: string, + ): { guard: string + callSite?: string armedAtMs: number before: RefusalGroupView[] + focusedTab: RefusalTabView | undefined } { return { guard, + ...(callSite === undefined ? {} : { callSite }), armedAtMs: performance.now(), before: this.refusalGroupViews(), + focusedTab: this.refusalTabView( + vscode.window.tabGroups.activeTabGroup.activeTab, + ), + } + } + + /** + * Describes one tab as a refusal report keeps it: the raw label, the input kind and the preview and pinned flags; called by {@link refusalGroupViews} and {@link armPlacementReport}. + * @param tab - The tab, or `undefined` for a group that shows none. + * @returns The view, or `undefined`; the label still needs {@link sanitizeRefusalGroups}'s treatment before it is shown. + * @example this.refusalTabView(group.activeTab) // => { label: 'auth.ts', kind: 'text', isPreview: false, isPinned: false } + */ + private refusalTabView( + tab: vscode.Tab | undefined, + ): RefusalTabView | undefined { + if (!tab) return undefined + return { + label: tab.label, + kind: + tab.input instanceof vscode.TabInputText + ? 'text' + : tab.input instanceof vscode.TabInputWebview + ? `webview ${tab.input.viewType}` + : tab.input instanceof vscode.TabInputTextDiff + ? 'diff' + : 'other', + isPreview: tab.isPreview, + isPinned: tab.isPinned, } } @@ -22301,21 +22419,18 @@ export class StudyWorkspace { */ private refusalGroupViews(): RefusalGroupView[] { const active = vscode.window.tabGroups.activeTabGroup - return vscode.window.tabGroups.all.map((group) => ({ - viewColumn: group.viewColumn, - isActive: group === active, - tabs: group.tabs.slice(0, REFUSAL_REPORT_GROUP_TABS).map((tab) => ({ - label: tab.label, - kind: - tab.input instanceof vscode.TabInputText - ? 'text' - : tab.input instanceof vscode.TabInputWebview - ? `webview ${tab.input.viewType}` - : tab.input instanceof vscode.TabInputTextDiff - ? 'diff' - : 'other', - })), - })) + return vscode.window.tabGroups.all.map((group) => { + const activeTab = this.refusalTabView(group.activeTab) + return { + viewColumn: group.viewColumn, + isActive: group === active, + tabs: group.tabs.slice(0, REFUSAL_REPORT_GROUP_TABS).map((tab) => { + const { label, kind } = this.refusalTabView(tab)! + return { label, kind } + }), + ...(activeTab ? { activeTab } : {}), + } + }) } /** @@ -22334,6 +22449,42 @@ export class StudyWorkspace { ...tab, label: sanitizeCause(tab.label, roots), })), + ...(group.activeTab + ? { + activeTab: { + ...group.activeTab, + label: sanitizeCause(group.activeTab.label, roots), + }, + } + : {}), + })) + } + + /** + * Sanitizes one tab of a refusal report like {@link sanitizeRefusalGroups} does its groups'; called by {@link refusePlacement} for the focused tab and the active tab. + * @param tab - The tab view, or `undefined`. + * @returns The same view with a sanitized label. + * @example this.sanitizeRefusalTab(armed.focusedTab) + */ + private sanitizeRefusalTab( + tab: RefusalTabView | undefined, + ): RefusalTabView | undefined { + return tab + ? { ...tab, label: sanitizeCause(tab.label, this.privateRoots()) } + : undefined + } + + /** + * Sanitizes the free text of timed notes (tab labels in a tab operation, for one) before a refusal report shows them; called by {@link refusePlacement}. + * @param notes - Notes from {@link placementEvents} or {@link placementEffects}. + * @returns The same notes with sanitized texts. + * @example this.sanitizeRefusalNotes(this.placementEvents.since(armedAtMs)) + */ + private sanitizeRefusalNotes(notes: readonly RefusalEvent[]): RefusalEvent[] { + const roots = this.privateRoots() + return notes.map((note) => ({ + ...note, + what: sanitizeCause(note.what, roots), })) } @@ -22365,25 +22516,50 @@ export class StudyWorkspace { } /** - * Writes one Output › Codebook entry that names the refusing guard and what it saw, and returns the refusal the guard throws; called by the three placement guards in place of their bare error (F382). - * The sentence the learner reads is unchanged; the entry carries the groups before and after, the editor's events since the guard was armed and, when it was the cause, the active text editor change. + * Writes one Output › Codebook entry that names the refusing guard and what it saw, and returns the refusal the guard throws; called by the placement guards in place of their bare error (F382). + * The sentence the learner reads is unchanged; the entry carries the guard's call site and failed checks when it names them, the groups before and after with each group's front tab, the focused tab at arming against the active tab now, the editor's events since the guard was armed (group changes, tab operations, pane view-state changes, the active text editor) and the native effects Codebook itself issued since then (N55-F4, Cycle 0056 task 17). * @param armed - What {@link armPlacementReport} noted. + * @param failedChecks - The comparisons that failed, when the guard keeps them apart. * @returns The error with the refusal sentence. - * @example throw this.refusePlacement(armed) - */ - private refusePlacement(armed: { - guard: string - armedAtMs: number - before: RefusalGroupView[] - }): Error { + * @example throw this.refusePlacement(armed, ['focus group']) + */ + private refusePlacement( + armed: { + guard: string + callSite?: string + armedAtMs: number + before: RefusalGroupView[] + focusedTab?: RefusalTabView | undefined + }, + failedChecks?: readonly string[], + ): Error { const nowMs = performance.now() const cause = describePlacementRefusal({ guard: armed.guard, + ...(armed.callSite === undefined ? {} : { callSite: armed.callSite }), + ...(failedChecks === undefined ? {} : { failedChecks }), armedAtMs: armed.armedAtMs, nowMs, before: this.sanitizeRefusalGroups(armed.before), after: this.sanitizeRefusalGroups(this.refusalGroupViews()), - events: this.placementEvents.since(armed.armedAtMs), + ...('focusedTab' in armed + ? { + focus: { + atArming: this.sanitizeRefusalTab(armed.focusedTab), + atRefusal: this.sanitizeRefusalTab( + this.refusalTabView( + vscode.window.tabGroups.activeTabGroup.activeTab, + ), + ), + }, + } + : {}), + events: this.sanitizeRefusalNotes( + this.placementEvents.since(armed.armedAtMs), + ), + effects: this.sanitizeRefusalNotes( + this.placementEffects.since(armed.armedAtMs), + ), }) this.diagnostics?.log({ surface: 'presentation:placement-refused', cause }) return new Error( @@ -22405,6 +22581,116 @@ export class StudyWorkspace { ) } + /** + * Notes one tab operation for the refusal report, as the labels of the tabs opened, closed and changed; called by the tab listener {@link installReaders} registers. + * The labels are raw here and sanitized only when a refusal reports them ({@link sanitizeRefusalNotes}). + * @param event - The editor's change. + * @example this.notePlacementTabEvent(event) // records 'tabs opened [] closed ["data.ts"] changed []' + */ + private notePlacementTabEvent(event: vscode.TabChangeEvent): void { + const labels = (tabs: readonly vscode.Tab[]): string => + `[${tabs + .slice(0, REFUSAL_REPORT_GROUP_TABS) + .map((tab) => `"${tab.label}"`) + .join(',')}]` + this.placementEvents.record( + performance.now(), + `tabs opened ${labels(event.opened)} closed ${labels(event.closed)} changed ${labels(event.changed)}`, + ) + } + + /** + * Notes one view-state report of an owned pane for the refusal report: whether it is active and visible and the column the editor gives it; called by the view-state listeners of {@link bindApplicationPanel} and {@link bindContextPanel}. + * @param name - Which pane reported. + * @param panel - The pane. + * @example this.notePlacementViewState('Application', panel) // records 'Application view state active=true visible=true column=1' + */ + private notePlacementViewState( + name: 'Application' | 'Study', + panel: vscode.WebviewPanel, + ): void { + this.placementEvents.record( + performance.now(), + `${name} view state active=${panel.active} visible=${panel.visible} column=${panel.viewColumn ?? 'none'}`, + ) + } + + /** + * Notes one native effect Codebook itself issues (a reveal, a disposal, an editor command, a shown document), so a refusal's report lists what this host did while its guard waited and a native take can tell an editor report that answers it from a learner's change; called by {@link executeEditorCommand}, {@link showTextDocumentNoted}, {@link revealPane} and {@link disposePane}. + * @param what - The effect in words, with its column and `preserveFocus` where it has them. + * @example this.noteNativeEffect('reveal Application column 1 preserveFocus true') + */ + private noteNativeEffect(what: string): void { + this.placementEffects.record(performance.now(), what) + } + + /** + * Runs an editor command and notes it as one of Codebook's own effects; every command this class sends goes through here, instead of `this.executeEditorCommand`, except the reads of the layout and context keys, which change nothing. + * @param command - The command id. + * @param rest - Its arguments. + * @returns What the editor's command returns. + * @example await this.executeEditorCommand('workbench.action.focusFirstEditorGroup') + */ + private executeEditorCommand( + command: string, + ...rest: unknown[] + ): Thenable { + if (command !== 'vscode.getEditorLayout' && command !== 'setContext') + this.noteNativeEffect(`command ${command}`) + return vscode.commands.executeCommand(command, ...rest) + } + + /** + * Shows a document in the editor and notes it as one of Codebook's own effects, with its column and `preserveFocus`; every `showTextDocument` this class sends goes through here. + * @param target - The document or its uri. + * @param options - Where and how to show it. + * @returns The editor the document is shown in. + * @example await this.showTextDocumentNoted(document, { viewColumn: 2, preserveFocus: true }) + */ + private showTextDocumentNoted( + target: vscode.TextDocument | vscode.Uri, + options?: vscode.TextDocumentShowOptions, + ): Thenable { + this.noteNativeEffect( + `showTextDocument column ${options?.viewColumn ?? 'active'} preserveFocus ${options?.preserveFocus ?? false}`, + ) + return 'fsPath' in target + ? vscode.window.showTextDocument(target, options) + : vscode.window.showTextDocument(target, options) + } + + /** + * Reveals an owned pane and notes it as one of Codebook's own effects, with its column and `preserveFocus`. + * @param panel - The pane; `undefined` reveals nothing, as the optional call it replaces did. + * @param column - The column to reveal it in; `undefined` keeps the pane's own. + * @param preserveFocus - Whether the reveal leaves the keyboard where it is. + * @example this.revealPane(this.app, 1, true) + */ + private revealPane( + panel: vscode.WebviewPanel | undefined, + column: vscode.ViewColumn | undefined, + preserveFocus: boolean, + ): void { + if (!panel) return + this.noteNativeEffect( + `reveal ${panel.viewType} column ${column ?? 'own'} preserveFocus ${preserveFocus}`, + ) + panel.reveal(column, preserveFocus) + } + + /** + * Disposes an owned pane and notes it as one of Codebook's own effects, with the group tabs it leaves listed by the editor. + * @param panel - The pane; `undefined` disposes nothing. + * @example this.disposePane(this.contextPanel) + */ + private disposePane(panel: vscode.WebviewPanel | undefined): void { + if (!panel) return + this.noteNativeEffect( + `dispose ${panel.viewType} column ${panel.viewColumn ?? 'none'}`, + ) + panel.dispose() + } + /** Retains logical ownership across Cursor's wrapper-only tab-model resync, never across moved or replaced groups. @example this.observeEditorGroups(event) */ private observeEditorGroups(event?: vscode.TabGroupChangeEvent): void { const current = vscode.window.tabGroups.all, @@ -24840,7 +25126,7 @@ export class StudyWorkspace { */ private async editorAreaHidden(): Promise { return isCollapsedEditorLayout( - await vscode.commands.executeCommand('vscode.getEditorLayout'), + await this.executeEditorCommand('vscode.getEditorLayout'), ) } @@ -25558,7 +25844,7 @@ export class StudyWorkspace { ) const document = await vscode.workspace.openTextDocument(slot.uri) if (!isCurrent()) return - await vscode.window.showTextDocument(document, { + await this.showTextDocumentNoted(document, { viewColumn: holder?.viewColumn ?? this.nodeSourceSlotColumn(slot.uri, undefined), preserveFocus: !takesKeyboard, @@ -25740,7 +26026,7 @@ export class StudyWorkspace { const document = await vscode.workspace.openTextDocument(uri) if (request !== this.selectedNodeHistoryRequest) return 'overtaken' // A renamed slot opens where the earlier one is, before that tab closes: a group the slot alone filled is never emptied, which `workbench.editor.closeEmptyGroups` would remove. - await vscode.window.showTextDocument(document, { + await this.showTextDocumentNoted(document, { viewColumn: this.nodeSourceSlotColumn(uri, earlierUri), preserveFocus: true, preview: false, diff --git a/extension/src/tests/failedPlacementChecks.test.ts b/extension/src/tests/failedPlacementChecks.test.ts new file mode 100644 index 00000000..c936a712 --- /dev/null +++ b/extension/src/tests/failedPlacementChecks.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + failedPlacementChecks, + type PlacementChecks, +} from '../utils/failedPlacementChecks' + +const ALL_SAME: PlacementChecks = { + groups: true, + panePositions: true, + focusGroup: true, + webviewTab: true, + closeRevision: true, + source: true, + appActive: true, + contextActive: true, +} + +// Value: protects=a refusal's report names every comparison that failed, in report order, with the words the native take reads; fails_when=a failed comparison is left out or renamed; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=how the guard computes each comparison +test('names every failed comparison of a guard that retains the focus', () => { + // Arrange + const checks: PlacementChecks = { + groups: false, + panePositions: false, + focusGroup: false, + webviewTab: false, + closeRevision: false, + source: false, + appActive: false, + contextActive: false, + } + + // Act + const failed = failedPlacementChecks(checks, true) + + // Assert + assert.deepEqual(failed, [ + 'groups', + 'pane positions', + 'focus group', + 'webview tab', + 'close revision', + 'source', + 'app active', + 'context active', + ]) +}) + +// Value: protects=a guard that does not retain the focus is refused only by the groups and the pane positions, as before; fails_when=a changed focus comparison is reported (or refuses) for such a guard; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=the guard's own decision +test('ignores the focus comparisons for a guard that retains no focus', () => { + // Arrange + const checks: PlacementChecks = { + ...ALL_SAME, + panePositions: false, + focusGroup: false, + source: false, + } + + // Act + const failed = failedPlacementChecks(checks, false) + + // Assert + assert.deepEqual(failed, ['pane positions']) +}) + +// Value: protects=a guard whose comparisons all hold passes; fails_when=a passing guard reports a failed comparison; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=the refusal itself +test('names nothing when every comparison holds', () => { + // Arrange + const checks = ALL_SAME + + // Act + const failed = failedPlacementChecks(checks, true) + + // Assert + assert.deepEqual(failed, []) +}) diff --git a/extension/src/tests/placementRefusalReport.test.ts b/extension/src/tests/placementRefusalReport.test.ts index 8db46c7d..1fd69d38 100644 --- a/extension/src/tests/placementRefusalReport.test.ts +++ b/extension/src/tests/placementRefusalReport.test.ts @@ -70,3 +70,122 @@ test('keeps the last 24 editor events and lists those since the guard was armed' /Events while armed:\n \+10 ms event 28\n \+20 ms event 29$/, ) }) + +// Value: protects=the lines N55-F4's native reproduction reads: the call site, the failed comparisons, each group's front tab with preview and pinned flags, the focused tab at arming against the active tab at the refusal, and Codebook's own effects with their time since arming; fails_when=a line is missing, a flag is not shown, or the effects are mixed with the editor's events; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=the guard that names them +test('names the call site, the failed checks, the front tabs, the focused tab and Codebook’s own effects in the refusal report', () => { + // Arrange + const studyTab = { + label: 'codebook · Study', + kind: 'webview codebook.studyContext', + isPreview: false, + isPinned: true, + } + const nextTab = { + label: 'data.ts', + kind: 'text', + isPreview: true, + isPinned: false, + } + const before = [ + { + viewColumn: 3, + isActive: true, + tabs: [ + { label: 'codebook · Study', kind: 'webview codebook.studyContext' }, + { label: 'data.ts', kind: 'text' }, + ], + activeTab: studyTab, + }, + ] + const after = [{ ...before[0]!, activeTab: nextTab }] + + // Act + const report = describePlacementRefusal({ + guard: 'retainPresentationPlacement', + callSite: 'readMainEditorGrid/assertInitial', + failedChecks: ['focus group', 'webview tab'], + armedAtMs: 100, + nowMs: 102, + before, + after, + focus: { atArming: studyTab, atRefusal: nextTab }, + events: [ + { atMs: 101, what: 'tabs opened [] closed [] changed ["data.ts"]' }, + ], + effects: [ + { + atMs: 100, + what: 'reveal codebook.scopedBrowser column 1 preserveFocus true', + }, + ], + }) + + // Assert + assert.equal( + report, + [ + 'Guard: retainPresentationPlacement', + 'Call site: readMainEditorGrid/assertInitial', + 'Failed checks: focus group, webview tab', + 'Armed 2 ms before the refusal', + 'Groups before: 3* [codebook · Study (webview codebook.studyContext), data.ts (text)]', + 'Groups after: 3* [codebook · Study (webview codebook.studyContext), data.ts (text)]', + 'Front tabs before: 3* "codebook · Study" (webview codebook.studyContext, pinned)', + 'Front tabs after: 3* "data.ts" (text, preview)', + 'Focused tab at arming: "codebook · Study" (webview codebook.studyContext, pinned)', + 'Active tab at refusal: "data.ts" (text, preview)', + 'Events while armed:', + ' +1 ms tabs opened [] closed [] changed ["data.ts"]', + 'Codebook’s own effects since arming:', + ' +0 ms reveal codebook.scopedBrowser column 1 preserveFocus true', + ].join('\n'), + ) +}) + +// Value: protects=a guard that retains no focus tab and has no effects still prints a report without the new lines' guesses: no front tabs, no focus lines and an empty effects list that says none; fails_when=the report invents a front tab or leaves the effects heading without a line; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=the guards that name them +test('says none when no effect of Codebook’s own was issued since arming and shows no focus lines without them', () => { + // Arrange + const group = { viewColumn: 1, isActive: true, tabs: [] } + + // Act + const report = describePlacementRefusal({ + guard: 'placement', + failedChecks: [], + armedAtMs: 0, + nowMs: 5, + before: [group], + after: [group], + events: [], + effects: [], + }) + + // Assert + assert.equal( + report, + [ + 'Guard: placement', + 'Failed checks: none', + 'Armed 5 ms before the refusal', + 'Groups before: 1* []', + 'Groups after: 1* []', + 'Events while armed:', + ' none', + 'Codebook’s own effects since arming:', + ' none', + ].join('\n'), + ) +}) + +// Value: protects=Codebook's own effects are kept apart from the editor's events and bounded by the capacity the log is given (48 for effects); fails_when=a log built for effects keeps only 24 notes; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=what is recorded +test('keeps as many notes as the capacity the log was given', () => { + // Arrange + const log = new PlacementEventLog(48) + + // Act + for (let index = 0; index < 60; index += 1) + log.record(index, `effect ${index}`) + + // Assert + assert.equal(log.since(0).length, 48) + assert.equal(log.since(0)[0]!.what, 'effect 12') +}) diff --git a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts index 527d9eda..7e65c03a 100644 --- a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts +++ b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts @@ -1355,7 +1355,7 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo ).length, 1, ) - assert.equal(sanitizedLabels, 10) + assert.equal(sanitizedLabels, 17) } finally { context.mock.restoreAll() await harness.close() diff --git a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts index cb81c52a..f3740af9 100644 --- a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts +++ b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts @@ -151,3 +151,114 @@ test('the first Open replay after a window reload retires the restored empty loc await harness.close() } }) + +// Value: protects=the refusal report of N55-F4 refusal 1's diagnostics: a refusal of the focus guard names its call site, the comparison that failed, the tab each group showed, the focused tab at arming against the active tab at the refusal and the editor command Codebook itself issued since arming; fails_when=the report names only the guard and the groups, so a native take cannot tell the editor's late report from Codebook's own focus effect; why_new=Cycle 0056 task 17 stage 1; seam=workspaceHarness with two groups, the guard armed through its seam, Codebook's own focus command, the diagnostics log; no_claim=which of Cursor's reports caused the native refusal (N56 step 1) +test('a refused focus guard names its call site, the failed comparison, the front tabs and Codebook’s own effect', async () => { + // Arrange + const harness = await workspaceHarness(false) + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.useNativeLayout({ orientation: 0, groups: [{}, {}] }) + try { + harness.focusMutableDraft('first.ts', 1) + harness.focusMutableDraft('second.ts', 2) + const assertPlacement = harness.reader['retainPresentationPlacement']( + 'exampleCaller/assertExample', + ) + + // Act: Codebook itself moves the keyboard to the first group while the guard waits. + await harness.reader['executeEditorCommand']( + 'workbench.action.focusFirstEditorGroup', + ) + let refusal: unknown + try { + assertPlacement() + } catch (error) { + refusal = error + } + + // Assert + assert.equal( + String(refusal), + 'Error: Native pane, group or focus changed while presentation waited; the learner arrangement was preserved.', + ) + const entries = logged.filter( + (entry) => entry.surface === 'presentation:placement-refused', + ) + assert.equal(entries.length, 1) + const lines = entries[0]!.cause.split('\n') + assert.deepEqual(lines.slice(0, 3), [ + 'Guard: retainPresentationPlacement', + 'Call site: exampleCaller/assertExample', + 'Failed checks: focus group, source', + ]) + assert.deepEqual( + lines.filter( + (line) => + line.startsWith('Front tabs') || + line.startsWith('Focused tab') || + line.startsWith('Active tab'), + ), + [ + 'Front tabs before: 1 "first.ts" (text) | 2* "second.ts" (text)', + 'Front tabs after: 1* "first.ts" (text) | 2 "second.ts" (text)', + 'Focused tab at arming: "second.ts" (text)', + 'Active tab at refusal: "first.ts" (text)', + ], + ) + assert.match( + entries[0]!.cause, + /Codebook’s own effects since arming:\n {2}\+\d+ ms command workbench\.action\.focusFirstEditorGroup$/, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=the editor reports a refusal's entry lists besides the group changes: the tab operation (which tab closed) and the view-state report of an owned pane (active, visible, column); fails_when=a refusal after a tab closed or a pane reported its view state lists no such event, so a native take cannot tell which report preceded the refusal; why_new=Cycle 0056 task 17 stage 1 (the group events were all the log held); seam=workspaceHarness with an open reader, the guard armed through its seam, closeTabByHand, the Application pane's view-state notification, the diagnostics log; no_claim=the order Cursor sends them in +test('a refused guard lists the tab operation and the pane view-state report the editor sent while it waited', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const learnerDraft = harness.focusMutableDraft('learner.ts', 2) + await harness.reader.open() + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + assert.ok(application) + const assertPlacement = harness.reader['retainPresentationPlacement']( + 'exampleCaller/assertEvents', + ) + + // Act + application.notifyViewState() + harness.closeTabByHand(learnerDraft.tab) + // The learner then puts the keyboard in another file, which the guard refuses. + harness.focusMutableDraft('other.ts', 1) + try { + assertPlacement() + } catch { + // The refusal is written to the log; the test reads it there. + } + + // Assert + const entries = logged.filter( + (entry) => entry.surface === 'presentation:placement-refused', + ) + assert.equal(entries.length, 1) + assert.match( + entries[0]!.cause, + /Events while armed:\n(?:.*\n)*? {2}\+\d+ ms Application view state active=(?:true|false) visible=(?:true|false) column=\d+\n(?:.*\n)*? {2}\+\d+ ms tabs opened \[\] closed \["learner\.ts"\] changed \[\]/, + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/utils/failedPlacementChecks.ts b/extension/src/utils/failedPlacementChecks.ts new file mode 100644 index 00000000..84dd7720 --- /dev/null +++ b/extension/src/utils/failedPlacementChecks.ts @@ -0,0 +1,50 @@ +/** The comparisons a placement guard makes between arming and asserting, each `true` when nothing changed. */ +export type PlacementChecks = { + groups: boolean + panePositions: boolean + focusGroup: boolean + webviewTab: boolean + closeRevision: boolean + source: boolean + appActive: boolean + contextActive: boolean +} + +// The comparisons that only a guard retaining the learner's focus makes. +const FOCUS_CHECKS = [ + 'focusGroup', + 'webviewTab', + 'closeRevision', + 'source', + 'appActive', + 'contextActive', +] as const satisfies readonly (keyof PlacementChecks)[] + +// How a refusal report names each comparison. +const CHECK_NAMES: Record = { + groups: 'groups', + panePositions: 'pane positions', + focusGroup: 'focus group', + webviewTab: 'webview tab', + closeRevision: 'close revision', + source: 'source', + appActive: 'app active', + contextActive: 'context active', +} + +/** + * Names the comparisons of a placement guard that failed, so a refusal's report says which one refused; called by {@link StudyWorkspace}'s `retainPresentationPlacement` guard each time it asserts. + * The focus comparisons count only for a guard that retains the focus; a guard that does not retain it is not refused by them. + * @param checks - Each comparison, `true` when nothing changed. + * @param retainFocus - Whether the guard retains the learner's focus. + * @returns The names of the failed comparisons in report order; empty when the guard passes. + * @example failedPlacementChecks({ ...allSame, focusGroup: false }, true) // => ['focus group'] + */ +export function failedPlacementChecks( + checks: PlacementChecks, + retainFocus: boolean, +): string[] { + const names: (keyof PlacementChecks)[] = ['groups', 'panePositions'] + if (retainFocus) names.push(...FOCUS_CHECKS) + return names.filter((name) => !checks[name]).map((name) => CHECK_NAMES[name]) +} diff --git a/extension/src/utils/placementRefusalReport.ts b/extension/src/utils/placementRefusalReport.ts index b7622533..9d3356ed 100644 --- a/extension/src/utils/placementRefusalReport.ts +++ b/extension/src/utils/placementRefusalReport.ts @@ -4,11 +4,20 @@ import { REFUSAL_REPORT_LABEL_CHARS, } from '../constants' -/** One editor group as a refusal report names it. */ +/** One tab as a refusal report names the active tab of a group: its label, input kind and whether it is a preview or a pinned tab. */ +export type RefusalTabView = { + label: string + kind: string + isPreview: boolean + isPinned: boolean +} + +/** One editor group as a refusal report names it; `activeTab` is the tab the group showed, when it showed one. */ export type RefusalGroupView = { viewColumn: number isActive: boolean tabs: readonly { label: string; kind: string }[] + activeTab?: RefusalTabView } /** One change the editor reported while a placement guard was armed. */ @@ -18,7 +27,8 @@ export type RefusalEvent = { } /** - * Keeps the last editor events (tab-group changes and active text editor changes) with their time, so a placement refusal can say what the editor reported while its guard waited; fed by {@link StudyWorkspace}'s group listener and read by {@link describePlacementRefusal}. + * Keeps the last notes (the editor's tab-group changes, tab operations, active text editor changes and pane view-state changes, or the effects Codebook itself issued) with their time, so a placement refusal can say what happened while its guard waited; fed by {@link StudyWorkspace}'s listeners and effect wrappers and read by {@link describePlacementRefusal}. + * @param capacity - How many notes are kept; the editor's events keep {@link REFUSAL_REPORT_EVENTS}, Codebook's own effects `REFUSAL_REPORT_EFFECTS` (set by the caller). * @example * const log = new PlacementEventLog() * log.record(10, 'groups closed [2]') @@ -26,10 +36,12 @@ export type RefusalEvent = { export class PlacementEventLog { private readonly events: RefusalEvent[] = [] - /** Adds one event, dropping the oldest beyond {@link REFUSAL_REPORT_EVENTS}. @example log.record(performance.now(), 'groups opened [3]') */ + constructor(private readonly capacity: number = REFUSAL_REPORT_EVENTS) {} + + /** Adds one note, dropping the oldest beyond the capacity. @example log.record(performance.now(), 'groups opened [3]') */ record(atMs: number, what: string): void { this.events.push({ atMs, what }) - if (this.events.length > REFUSAL_REPORT_EVENTS) this.events.shift() + if (this.events.length > this.capacity) this.events.shift() } /** Lists the events at or after a time. @example log.since(armedAtMs) // => [{ atMs: 12, what: 'groups closed [2]' }] */ @@ -38,6 +50,22 @@ export class PlacementEventLog { } } +/** + * Words one tab for a refusal report: the label (cut), the input kind and the preview and pinned flags. + * @param tab - The tab, with a sanitized label; `undefined` for a group or an editor that shows none. + * @returns The tab's words, or `none`. + * @example describeRefusalTab({ label: 'data.ts', kind: 'text', isPreview: true, isPinned: false }) // => '"data.ts" (text, preview)' + */ +function describeRefusalTab(tab: RefusalTabView | undefined): string { + if (!tab) return 'none' + const flags = [ + tab.kind, + ...(tab.isPreview ? ['preview'] : []), + ...(tab.isPinned ? ['pinned'] : []), + ] + return `"${Array.from(tab.label).slice(0, REFUSAL_REPORT_LABEL_CHARS).join('')}" (${flags.join(', ')})` +} + /** * Words the editor groups for a refusal report: one line per group with its column, whether it is the active group and its tabs (label cut, input kind), bounded. * @param groups - The groups, in the editor's order; labels are already sanitized. @@ -58,31 +86,91 @@ function describeRefusalGroups(groups: readonly RefusalGroupView[]): string[] { } /** - * Words what a refused placement guard saw, for one Output › Codebook entry that names the guard, the groups before and after and the editor's events with the milliseconds since the guard was armed; called by {@link StudyWorkspace} when one of its three placement guards refuses. - * @param input - The guard's name, the arm time, the groups at arm time and now, and the events since arming. + * Words the tab each group showed, one entry per group that showed one, for the two lines that name the front tabs before and after. + * @param groups - The groups, in the editor's order; labels are already sanitized. + * @returns `column tab` entries, or an empty list when no group named its front tab. + * @example describeFrontTabs([{ viewColumn: 3, isActive: true, tabs: [], activeTab: { label: 'data.ts', kind: 'text', isPreview: false, isPinned: false } }]) // => ['3* "data.ts" (text)'] + */ +function describeFrontTabs(groups: readonly RefusalGroupView[]): string[] { + return groups.flatMap((group) => + group.activeTab + ? [ + `${group.viewColumn}${group.isActive ? '*' : ''} ${describeRefusalTab(group.activeTab)}`, + ] + : [], + ) +} + +/** + * Words the lines of a list of timed notes, as milliseconds since the guard was armed. + * @param events - The notes, oldest first. + * @param armedAtMs - The time the guard was armed. + * @returns One line per note, or one line saying `none`. + * @example describeRefusalEvents([{ atMs: 12, what: 'groups closed [2]' }], 10) // => [' +2 ms groups closed [2]'] + */ +function describeRefusalEvents( + events: readonly RefusalEvent[], + armedAtMs: number, +): string[] { + return events.length + ? events.map( + (event) => ` +${Math.round(event.atMs - armedAtMs)} ms ${event.what}`, + ) + : [' none'] +} + +/** + * Words what a refused placement guard saw, for one Output › Codebook entry that names the guard, the groups before and after and the editor's events with the milliseconds since the guard was armed; called by {@link StudyWorkspace} when one of its placement guards refuses. + * The guard's call site, the checks that failed, each group's front tab, the focused tab at arming against the active tab at the refusal and the effects Codebook itself issued since arming are optional: a guard that does not name them prints the first lines only. + * @param input - The guard's name, the arm time, the groups at arm time and now, and the events since arming; optionally the call site, the failed checks, the focused tab then and the active tab now, and Codebook's own effects since arming. * @returns The multi-line cause text. * @example describePlacementRefusal({ guard: 'placement', armedAtMs: 0, nowMs: 130, before, after, events }) // => 'Guard: placement\nArmed 130 ms before the refusal\nBefore: …' */ export function describePlacementRefusal(input: { guard: string + callSite?: string + failedChecks?: readonly string[] armedAtMs: number nowMs: number before: readonly RefusalGroupView[] after: readonly RefusalGroupView[] + focus?: { + atArming: RefusalTabView | undefined + atRefusal: RefusalTabView | undefined + } events: readonly RefusalEvent[] + effects?: readonly RefusalEvent[] }): string { - const events = input.events.length - ? input.events.map( - (event) => - ` +${Math.round(event.atMs - input.armedAtMs)} ms ${event.what}`, - ) - : [' none'] return [ `Guard: ${input.guard}`, + ...(input.callSite === undefined ? [] : [`Call site: ${input.callSite}`]), + ...(input.failedChecks === undefined + ? [] + : [ + `Failed checks: ${input.failedChecks.length ? input.failedChecks.join(', ') : 'none'}`, + ]), `Armed ${Math.round(input.nowMs - input.armedAtMs)} ms before the refusal`, `Groups before: ${describeRefusalGroups(input.before).join(' | ')}`, `Groups after: ${describeRefusalGroups(input.after).join(' | ')}`, + ...(input.before.some((group) => group.activeTab) + ? [ + `Front tabs before: ${describeFrontTabs(input.before).join(' | ')}`, + `Front tabs after: ${describeFrontTabs(input.after).join(' | ')}`, + ] + : []), + ...(input.focus + ? [ + `Focused tab at arming: ${describeRefusalTab(input.focus.atArming)}`, + `Active tab at refusal: ${describeRefusalTab(input.focus.atRefusal)}`, + ] + : []), 'Events while armed:', - ...events, + ...describeRefusalEvents(input.events, input.armedAtMs), + ...(input.effects + ? [ + 'Codebook’s own effects since arming:', + ...describeRefusalEvents(input.effects, input.armedAtMs), + ] + : []), ].join('\n') } From e7c10614253ce3039c3f12bf9fb33c896f6fd657 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 00:49:17 +0900 Subject: [PATCH 05/31] fix: Cycle 0056 pt3: Close Study Workspace no longer fails with Webview is disposed after the editor closed the panes (task 17) --- extension/src/studyWorkspace.ts | 23 ++++++++----- .../src/tests/describePaneIdentity.test.ts | 33 +++++++++++++++++++ .../studyWorkspaceEntryPlacement.test.ts | 21 ++++++++++++ extension/src/tests/studyWorkspaceHarness.ts | 21 ++++++++++-- extension/src/utils/describePaneIdentity.ts | 17 ++++++++++ 5 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 extension/src/tests/describePaneIdentity.test.ts create mode 100644 extension/src/utils/describePaneIdentity.ts diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 87ee74d2..a3d60072 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -234,6 +234,7 @@ import { NODE_RECORDED_SOURCE_TEXT, } from './utils/describeNodeRecordedSource' import { describeNodeTestReplay } from './utils/describeNodeTestReplay' +import { describePaneIdentity } from './utils/describePaneIdentity' import { describeStudy } from './utils/describeStudy' import { describeViewportDelivery, @@ -22609,10 +22610,18 @@ export class StudyWorkspace { name: 'Application' | 'Study', panel: vscode.WebviewPanel, ): void { - this.placementEvents.record( - performance.now(), - `${name} view state active=${panel.active} visible=${panel.visible} column=${panel.viewColumn ?? 'none'}`, - ) + // A pane the editor disposed meanwhile throws from its getters; a note never fails the listener. + try { + this.placementEvents.record( + performance.now(), + `${name} view state active=${panel.active} visible=${panel.visible} column=${panel.viewColumn ?? 'none'}`, + ) + } catch { + this.placementEvents.record( + performance.now(), + `${name} view state of a disposed pane`, + ) + } } /** @@ -22673,7 +22682,7 @@ export class StudyWorkspace { ): void { if (!panel) return this.noteNativeEffect( - `reveal ${panel.viewType} column ${column ?? 'own'} preserveFocus ${preserveFocus}`, + `reveal ${describePaneIdentity(panel)} to column ${column ?? 'own'} preserveFocus ${preserveFocus}`, ) panel.reveal(column, preserveFocus) } @@ -22685,9 +22694,7 @@ export class StudyWorkspace { */ private disposePane(panel: vscode.WebviewPanel | undefined): void { if (!panel) return - this.noteNativeEffect( - `dispose ${panel.viewType} column ${panel.viewColumn ?? 'none'}`, - ) + this.noteNativeEffect(`dispose ${describePaneIdentity(panel)}`) panel.dispose() } diff --git a/extension/src/tests/describePaneIdentity.test.ts b/extension/src/tests/describePaneIdentity.test.ts new file mode 100644 index 00000000..b029ce9e --- /dev/null +++ b/extension/src/tests/describePaneIdentity.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { describePaneIdentity } from '../utils/describePaneIdentity' + +// Value: protects=the effect note of a pane the editor already disposed never throws; fails_when=the helper lets the editor's `Webview is disposed` through; why_new=Cycle 0056 task 17 (native finding on 8b76a578); seam=none; no_claim=the host's close +test('words a disposed pane as disposed instead of throwing', () => { + // Arrange + const disposedPane = { + get viewType(): string { + throw new Error('Webview is disposed') + }, + viewColumn: 1, + } + + // Act + const words = describePaneIdentity(disposedPane) + + // Assert + assert.equal(words, 'disposed pane') +}) + +// Value: protects=a live pane's note names its type and column; fails_when=the note drops either; why_new=Cycle 0056 task 17; seam=none; no_claim=disposed panes +test('words a live pane by its type and column', () => { + // Arrange + const pane = { viewType: 'codebook.scopedBrowser', viewColumn: 1 } + + // Act + const words = describePaneIdentity(pane) + + // Assert + assert.equal(words, 'codebook.scopedBrowser column 1') +}) diff --git a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts index f3740af9..70dc8e54 100644 --- a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts +++ b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts @@ -262,3 +262,24 @@ test('a refused guard lists the tab operation and the pane view-state report the await harness.close() } }) + +// Value: protects=Codebook: Close Study Workspace after a replay opened (N56 native finding on 8b76a578): panes the editor already disposed with their tabs are disposed again without reading their identity, so the close ends without `Webview is disposed`; fails_when=the effect note reads viewType or viewColumn of a disposed panel, which throws as the editor's WebviewPanel does, and the close rejects; why_new=Cycle 0056 task 17 stage 1 (the new disposal note); seam=workspaceHarness whose disposed panels throw from viewType and viewColumn, an open reader with both panes; no_claim=the editor's own dialog +test('closing the Study workspace ends without an error when the editor already disposed its panes', async () => { + // Arrange + const harness = await workspaceHarness() + harness.throwFromDisposedPanelGetters() + try { + await harness.reader.open() + + // Act + const closing = harness.reader.close().then( + () => 'closed', + (error: unknown) => String(error), + ) + + // Assert + assert.equal(await closing, 'closed') + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index b7a3b603..53372928 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1090,6 +1090,8 @@ export async function workspaceHarness( let closeDisposedPanelTabsLater = false /** Whether a disposed pane that is the front tab of a group shared with other tabs first makes the next tab the active one through a tab notification, and closes its own tab later; see `activateNextTabBeforeDisposedPaneTabCloses`. */ let activatesNextTabBeforeDisposedPaneTabCloses = false + /** Whether a disposed panel's `viewType` and `viewColumn` throw `Webview is disposed`, as the editor's WebviewPanel does; see `throwFromDisposedPanelGetters`. */ + let throwsFromDisposedPanelGetters = false /** The next-tab notification of a disposed pane that the fake editor has not delivered yet; the first layout read after the disposal delivers it while the read is in flight. */ let pendingNextTabActivation: (() => void) | undefined // Cursor 3.23.12 keeps naming the chat's group as the active tab group after `workbench.action.focusFirstEditorGroup` gave the main editor the keyboard. Only a group event corrects the name: a whole tab-model rebuild names the active group (`workbench.desktop.main.js` 17269:31013, `extensionHostProcess.js` 131:33177), as does a group's activation (17269:28123, 131:33740); a tab notification is a tab operation, which leaves the named group as it is (131:34068), so it does not (Cycle 0046 row 673). @@ -2834,9 +2836,14 @@ export async function workspaceHarness( let actualViewColumn = options?.viewColumn ? nativeReaderColumn(options.viewColumn) : options?.viewColumn + let isDisposed = false const value = { - // As the editor's WebviewPanel names it, so a test can find one panel among the native ones. - viewType: kind, + // As the editor's WebviewPanel names it, so a test can find one panel among the native ones; after its disposal it throws, as the editor's does, once a test asks for that (`throwFromDisposedPanelGetters`). + get viewType(): string { + if (throwsFromDisposedPanelGetters && isDisposed) + throw new Error('Webview is disposed') + return kind + }, blockInnerFocus: false, webview: { html: '', @@ -2995,6 +3002,8 @@ export async function workspaceHarness( emit, /** The panel's column as the editor's WebviewPanel reports it: its tab's group's column, unless `reportPanelColumn` makes it report another. */ get viewColumn(): number | undefined { + if (throwsFromDisposedPanelGetters && isDisposed) + throw new Error('Webview is disposed') return reportedPanelColumns.has(kind) ? reportedPanelColumns.get(kind) : actualViewColumn @@ -3316,6 +3325,7 @@ export async function workspaceHarness( } }, dispose(): void { + isDisposed = true if (!panels.delete(value)) return // A disposed pane that had the keyboard leaves it to no editor. if ( @@ -5946,6 +5956,13 @@ export async function workspaceHarness( activateNextTabBeforeDisposedPaneTabCloses(): void { activatesNextTabBeforeDisposedPaneTabCloses = true }, + /** + * Makes a disposed panel's `viewType` and `viewColumn` throw `Webview is disposed`, as the editor's WebviewPanel does (`dispose()` itself stays idempotent). + * @example harness.throwFromDisposedPanelGetters() + */ + throwFromDisposedPanelGetters(): void { + throwsFromDisposedPanelGetters = true + }, /** * Delivers queued native tab changes after a regression changes window focus, for delayed pane-disposal checks. * @returns Once the pending changes ran; their automatic timers then have nothing left to deliver. diff --git a/extension/src/utils/describePaneIdentity.ts b/extension/src/utils/describePaneIdentity.ts new file mode 100644 index 00000000..49d78a7b --- /dev/null +++ b/extension/src/utils/describePaneIdentity.ts @@ -0,0 +1,17 @@ +/** + * Words a webview pane's type and column for a refusal report's effect note without ever throwing: the editor's `WebviewPanel` throws `Webview is disposed` from its getters once it is disposed, and a pane the editor closed with its tab is disposed again by Codebook's own close. + * Called by {@link StudyWorkspace}'s `revealPane` and `disposePane` before they act on the pane. + * @param panel - The pane, live or already disposed. + * @returns ` column `, or `disposed pane` when a getter throws. + * @example describePaneIdentity(livePanel) // => 'codebook.scopedBrowser column 1' + */ +export function describePaneIdentity(panel: { + readonly viewType: string + readonly viewColumn: number | undefined +}): string { + try { + return `${panel.viewType} column ${panel.viewColumn ?? 'own'}` + } catch { + return 'disposed pane' + } +} From 5e0eee39ce4a4c4eaf24e37ed3b260c1ebc5c3fe Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 01:00:19 +0900 Subject: [PATCH 06/31] fix: Cycle 0056 pt3: a test replay opens although the editor reports the Application's group late (task 17) --- extension/src/studyWorkspace.ts | 36 +++++---- extension/src/tests/studyWorkspaceHarness.ts | 75 +++++++++++++++++++ .../src/tests/studyWorkspaceLayout.test.ts | 7 +- ...tudyWorkspaceTestReplayComposition.test.ts | 51 +++++++++++++ 4 files changed, 150 insertions(+), 19 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index a3d60072..d1911964 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -8457,20 +8457,12 @@ export class StudyWorkspace { historyRequest === this.historyRequest ) { const app = this.app - this.revealPane(app, undefined, true) - // The editor reports the reveal a moment later; the arrangement below would read that report as a change the learner made, so it waits, bounded, until the pane is the tab its group shows. - if (app) + // A pane that is already the tab its group shows needs no reveal: the editor would report the reveal anyway, a moment later and as a group activation, which the next guard reads as the learner's focus change (N55-F4 refusal 1, H2-a). + if (app && !(app.visible && this.applicationFrontsItsGroup(app))) { + this.revealPane(app, undefined, true) + // The editor reports the reveal a moment later; the arrangement below would read that report as a change the learner made, so it waits, bounded, until the pane is the tab its group shows. await waitUntil( - () => - this.app !== app || - vscode.window.tabGroups.all.some( - (group) => - group.viewColumn === app.viewColumn && - this.isCodebookPaneInput( - group.activeTab?.input, - 'codebook.scopedBrowser', - ), - ), + () => this.app !== app || this.applicationFrontsItsGroup(app), 'the revealed Application pane', PRESENTATION_READY_TIMEOUT_MS, signal, @@ -8478,6 +8470,7 @@ export class StudyWorkspace { // A pane the editor keeps behind another tab is no reason to end the entry; only the caller's own cancellation is. signal?.throwIfAborted() }) + } this.assertLifetime(lifetime) signal?.throwIfAborted() } @@ -22188,6 +22181,23 @@ export class StudyWorkspace { }) } + /** + * Says whether the Application pane is the tab its group shows; called by {@link open} before and after it reveals the pane. + * @param app - The Application pane. + * @returns `true` when its group's front tab is an Application pane. + * @example if (this.applicationFrontsItsGroup(app)) return + */ + private applicationFrontsItsGroup(app: vscode.WebviewPanel): boolean { + return vscode.window.tabGroups.all.some( + (group) => + group.viewColumn === app.viewColumn && + this.isCodebookPaneInput( + group.activeTab?.input, + 'codebook.scopedBrowser', + ), + ) + } + /** * Says whether the tab of a Study pane this reader disposed is still in the editor's tab model; called by {@link arrange} on a live or recording entry. * This host has one Study pane at most, so while it holds none, a Study pane tab that is not an earlier host's leftover is the disposed one. diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 53372928..23852022 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1094,6 +1094,15 @@ export async function workspaceHarness( let throwsFromDisposedPanelGetters = false /** The next-tab notification of a disposed pane that the fake editor has not delivered yet; the first layout read after the disposal delivers it while the read is in flight. */ let pendingNextTabActivation: (() => void) | undefined + /** Whether the fake Cursor reports the Application's group active a moment after a reveal of the Application that keeps the keyboard answered; see `activateApplicationGroupLateAfterReveal`. */ + let activatesApplicationGroupLateAfterReveal = false + /** Whether the fake Cursor reports the group `workbench.action.newGroupAbove` made active only after the command answered; see `activateNewGroupLateAfterInsertion`. */ + let activatesNewGroupLateAfterInsertion = false + /** The late group activations the fake editor has not reported yet; the first layout read after them reports them while it is in flight, else a timer does. */ + const pendingLateGroupActivations: { + report: () => void + atRead: () => boolean + }[] = [] // Cursor 3.23.12 keeps naming the chat's group as the active tab group after `workbench.action.focusFirstEditorGroup` gave the main editor the keyboard. Only a group event corrects the name: a whole tab-model rebuild names the active group (`workbench.desktop.main.js` 17269:31013, `extensionHostProcess.js` 131:33177), as does a group's activation (17269:28123, 131:33740); a tab notification is a tab operation, which leaves the named group as it is (131:34068), so it does not (Cycle 0046 row 673). let staleNamedGroup: (typeof groups)[number] | undefined // The tab that had the keyboard was closed while the focus was to be preserved, or its pane was disposed: no editor has the keyboard, the page body does (Cursor 3.23.12 after Close Study Workspace, Cycle 0045 obs15 P4), until something is focused again. @@ -1254,6 +1263,30 @@ export async function workspaceHarness( pendingEditorTimers.add(timer) return timer } + /** + * Queues one late report of a group's activation, as Cursor sends `$acceptTabGroupUpdate` for a group after the command that caused it has answered: the group becomes the active one and the editor tells its group listeners that it changed. The first layout read after the call reports it while the read is in flight, else a timer does {@link NEXT_TAB_ACTIVATION_DELAY_MS} later. + * @param group - The group, or a function that names it when the report is made. + * @param atRead - Whether a layout read that arrives now reports it; the timer reports it whatever this says. Defaults to every read. + * @example queueLateGroupActivation(() => groups.find((candidate) => candidate.tabs.includes(nativeTab))) + */ + function queueLateGroupActivation( + group: () => (typeof groups)[number] | undefined, + atRead: () => boolean = () => true, + ): void { + const pending = { report: (): void => {}, atRead } + const report = (): void => { + const index = pendingLateGroupActivations.indexOf(pending) + if (index < 0) return + pendingLateGroupActivations.splice(index, 1) + const activated = group() + if (!activated || !groups.includes(activated)) return + activeTabGroup = activated + changedGroups({ opened: [], closed: [], changed: [activated] }) + } + pending.report = report + pendingLateGroupActivations.push(pending) + runEditorChangeLater(report, NEXT_TAB_ACTIVATION_DELAY_MS) + } const automaticallyClosedColumns: number[] = [] let transientLayout = false, settledLayout: unknown, @@ -3064,6 +3097,17 @@ export async function workspaceHarness( ) }, reveal(column?: number, preserveFocus?: boolean): void { + // The editor reports the reveal's own group active a moment after the reveal answered, although the reveal keeps the keyboard. + if ( + activatesApplicationGroupLateAfterReveal && + kind === 'codebook.scopedBrowser' && + preserveFocus && + column === undefined && + !applyingLaterReveal + ) + queueLateGroupActivation(() => + groups.find((candidate) => candidate.tabs.includes(nativeTab)), + ) // The pane comes to the front of its group, or moves to another column, with the tab events of that, one turn after this call returned. const revealsInPlaceLater = revealsPanelsLater && @@ -4097,6 +4141,15 @@ export async function workspaceHarness( } if (name === 'workbench.action.newGroupAbove') { insertNativeAppGroup() + // The editor reports the new group active only after the command answered. + if (activatesNewGroupLateAfterInsertion) { + const inserted = activeTabGroup + // The report comes after the preparation's focus return has left the new group, so a guard armed by then sees the keyboard move there. + queueLateGroupActivation( + () => inserted, + () => activeTabGroup !== inserted, + ) + } return } if (name === 'workbench.action.lockEditorGroup') { @@ -4332,6 +4385,14 @@ export async function workspaceHarness( if (name === 'workbench.action.toggleMaximizedPanel') returnHiddenEditorArea() if (name === 'vscode.getEditorLayout') { + // Cursor reports a group's late activation while a layout read armed after the command is in flight. + const dueGroupActivations = pendingLateGroupActivations.filter( + (pending) => pending.atRead(), + ) + if (dueGroupActivations.length) { + await new Promise((resolve) => setImmediate(resolve)) + for (const pending of dueGroupActivations) pending.report() + } // Cursor reports the next tab active while a layout read that was armed after the disposal is in flight. const deliverNextTabActivation = pendingNextTabActivation if (deliverNextTabActivation) { @@ -5956,6 +6017,20 @@ export async function workspaceHarness( activateNextTabBeforeDisposedPaneTabCloses(): void { activatesNextTabBeforeDisposedPaneTabCloses = true }, + /** + * Makes the fake editor report the Application's group active a moment after every reveal of the Application that keeps the keyboard, as Cursor reports `app?.reveal(undefined, true)` late (N55-F4 hypothesis H2-a). + * @example harness.activateApplicationGroupLateAfterReveal() + */ + activateApplicationGroupLateAfterReveal(): void { + activatesApplicationGroupLateAfterReveal = true + }, + /** + * Makes the fake editor report the group `workbench.action.newGroupAbove` made active only after the command answered, as Cursor activates a new group late (N55-F4 hypothesis H2-b, row 650). + * @example harness.activateNewGroupLateAfterInsertion() + */ + activateNewGroupLateAfterInsertion(): void { + activatesNewGroupLateAfterInsertion = true + }, /** * Makes a disposed panel's `viewType` and `viewColumn` throw `Webview is disposed`, as the editor's WebviewPanel does (`dispose()` itself stays idempotent). * @example harness.throwFromDisposedPanelGetters() diff --git a/extension/src/tests/studyWorkspaceLayout.test.ts b/extension/src/tests/studyWorkspaceLayout.test.ts index d3fb8554..b88a6217 100644 --- a/extension/src/tests/studyWorkspaceLayout.test.ts +++ b/extension/src/tests/studyWorkspaceLayout.test.ts @@ -4332,17 +4332,12 @@ for (const cursor of [false, true]) .slice(reveals) .filter((reveal) => reveal.kind === 'codebook.scopedBrowser'), [ - // R3 Return parked App2; explicit source-derived entry first restores its owned App1 destination. + // R3 Return parked App2; explicit source-derived entry first restores its owned App1 destination. That reveal fronts the Application, so the entry's own reveal is skipped: a pane that already fronts its group needs none (Cycle 0056 task 17, H2-a). { kind: 'codebook.scopedBrowser', column: 1, preserveFocus: true, }, - { - kind: 'codebook.scopedBrowser', - column: undefined, - preserveFocus: true, - }, ], ) assert.equal(harness.panelColumn('codebook.scopedBrowser'), 1) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 2ce0780e..55361a44 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -271,6 +271,57 @@ test('a Saved studies choice sent from the Learning summary after the Study work } }) +// Value: protects=N55-F4 H2-a: a test replay chosen while the Application stands alone in its group opens although the editor reports the group active a moment after Codebook's reveal of the Application; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateApplicationGroupLateAfterReveal, the diagnostics log; no_claim=that Cursor reports it this way (N56 step 1 checks natively) +test('a test replay chosen while the Application is alone in its group opens without a placement refusal', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.activateApplicationGroupLateAfterReveal() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + // A refusal ends the entry without a landing, so the wait ends on either result. + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 || + logged.some( + (entry) => entry.surface === 'presentation:placement-refused', + ), + 'the Saved studies choice lands on the first stop or is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + logged.filter( + (entry) => entry.surface === 'presentation:placement-refused', + ), + [], + ) + await landsOn( + harness, + 2, + 'the Saved studies choice lands on the first stop', + ) + } finally { + await harness.close() + } +}) + for (const entry of ['Open replay', 'Saved studies'] as const) { // Value: protects=the learner's entry into a test replay in VS Code gives region 2's composition: the Study pane closes, the Application pane and the recorded source stay, and the Learning summary shows in the chat's place, moved beside the chat once per profile; fails_when=the Study pane stays open with the replay in its Test replay section, the summary keeps its placeholder, or the move does not run; why_new=Cycle 0051 DS-A (AC1): a test replay entered the Study composition; seam=workspaceHarness (VS Code), openLearningReplay and the Study pane's node-history message, the fake vscode.moveViews; no_claim=the native placement, which N51 (a) checks test(`${entry} in VS Code opens a test replay with the Study pane closed and the Learning summary beside the chat`, async () => { From fc54baca5eedf0656f1bd88260bfa20ae681a654 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 01:06:55 +0900 Subject: [PATCH 07/31] fix: Cycle 0056 pt3: a test replay opens although the editor activates the new Application group late (task 17) --- extension/src/constants.ts | 3 ++ extension/src/studyWorkspace.ts | 38 ++++++++++++++ ...tudyWorkspaceTestReplayComposition.test.ts | 51 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 5af6bbbb..a60f2509 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1854,3 +1854,6 @@ export const REFUSAL_REPORT_EVENTS = 24 /** The most native effects Codebook itself issued (reveals, disposals, editor commands, shown documents) that a {@link PlacementEventLog} keeps for a placement refusal report, in effects. */ export const REFUSAL_REPORT_EFFECTS = 48 + +/** How long a new Application group's preparation waits for the editor's own report that it activated the group, in ms; Cursor sends it after the command that made the group answered (N55-F4, row 650). It ends at the report, or here when the editor sends none. */ +export const LATE_GROUP_ACTIVATION_WAIT_MS = 150 diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index d1911964..7d59d546 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -86,6 +86,7 @@ import { SUMMARY_PLACEMENT_ACTION, FOREIGN_PREVIEW_ACTION, MOVE_VIEWS_COMMAND, + LATE_GROUP_ACTIVATION_WAIT_MS, REFUSAL_REPORT_EFFECTS, REFUSAL_REPORT_GROUP_TABS, SUMMARY_BESIDE_CHAT_MOVE_ARGUMENT, @@ -15842,6 +15843,9 @@ export class StudyWorkspace { lockReport, assertCurrent, ) + // The editor reports the new group's activation after the commands answered; no guard of the preparation arms before that report, which would read it as a change the learner made (N55-F4 refusal 1, H2-b). + await this.waitForLateGroupActivation(identity, signal) + assertCurrent() this.claimApplicationAllocation(identity, grid.groups.length + 1) // Decided right before the pane exists: an Open may have made one outside the queue since this transaction began (Cycle 0046 OCR1-7). const paneBefore = this.app @@ -22181,6 +22185,40 @@ export class StudyWorkspace { }) } + /** + * Waits for the editor's own report that it activated a group Codebook allocated, so the guards that follow start after it; called by {@link reserveApplicationGroup} once the group is allocated and locked. + * It ends at the first tab-group event that names the group as changed, or after {@link LATE_GROUP_ACTIVATION_WAIT_MS} when the editor sends none, or when the preparation is cancelled. + * @param identity - The allocated group's identity ({@link editorGroupIdentities}). + * @param signal - The preparation's cancellation. + * @returns Once the report arrived, the bound passed or the signal aborted; never rejects. + * @example await this.waitForLateGroupActivation(identity, signal) + */ + private async waitForLateGroupActivation( + identity: symbol, + signal: AbortSignal | undefined, + ): Promise { + await new Promise((resolve) => { + const subscription = vscode.window.tabGroups.onDidChangeTabGroups( + (event) => { + if ( + event.changed.some( + (group) => this.editorGroupIdentities.get(group) === identity, + ) + ) + done() + }, + ) + const timer = setTimeout(done, LATE_GROUP_ACTIVATION_WAIT_MS) + signal?.addEventListener('abort', done, { once: true }) + function done(): void { + clearTimeout(timer) + subscription.dispose() + signal?.removeEventListener('abort', done) + resolve() + } + }) + } + /** * Says whether the Application pane is the tab its group shows; called by {@link open} before and after it reveals the pane. * @param app - The Application pane. diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 55361a44..4ae933e0 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -322,6 +322,57 @@ test('a test replay chosen while the Application is alone in its group opens wit } }) +// Value: protects=N55-F4 H2-b: a test replay opens although the editor reports the Application's new group active only after newGroupAbove answered; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateNewGroupLateAfterInsertion, the diagnostics log; no_claim=that Cursor reports it this way (N56 step 1 checks natively) +test('a test replay whose Application group the editor activates late opens without a placement refusal', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.activateNewGroupLateAfterInsertion() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + // A refusal ends the entry without a landing, so the wait ends on either result. + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 || + logged.some( + (entry) => entry.surface === 'presentation:placement-refused', + ), + 'the Saved studies choice lands on the first stop or is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + logged.filter( + (entry) => entry.surface === 'presentation:placement-refused', + ), + [], + ) + await landsOn( + harness, + 2, + 'the Saved studies choice lands on the first stop', + ) + } finally { + await harness.close() + } +}) + for (const entry of ['Open replay', 'Saved studies'] as const) { // Value: protects=the learner's entry into a test replay in VS Code gives region 2's composition: the Study pane closes, the Application pane and the recorded source stay, and the Learning summary shows in the chat's place, moved beside the chat once per profile; fails_when=the Study pane stays open with the replay in its Test replay section, the summary keeps its placeholder, or the move does not run; why_new=Cycle 0051 DS-A (AC1): a test replay entered the Study composition; seam=workspaceHarness (VS Code), openLearningReplay and the Study pane's node-history message, the fake vscode.moveViews; no_claim=the native placement, which N51 (a) checks test(`${entry} in VS Code opens a test replay with the Study pane closed and the Learning summary beside the chat`, async () => { From 84b3b3540facbb0eae5b910d39e78a845b89fb2d Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 03:23:28 +0900 Subject: [PATCH 08/31] fix: Cycle 0056 pt3: the live-entry measurement test no longer fails when the chat holds the keyboard as the final arrangement measures (task 28) Race: beside the chat's editor part, readMainEditorGrid (extension/src/studyWorkspace.ts, borrow path ~20780-20880) reads vscode.getEditorLayout, borrows the main editor's first group when the chat holds the keyboard, and reads the layout a second time. Whether the chat holds the keyboard as the final arrangement's measurement begins depends on when the entry's deferred focus return reaches the chat, which a loaded lane shifts. The test counted layout reads before runCommands and expected exactly 1, so a correct borrowed measurement (2 reads) failed it. Probe (temporary, removed): chat.focusTab() just before the arrange call with the retiring context group made the besideChat test fail 3 of 3 runs: AssertionError: 2 !== 1 (studyWorkspaceEntry.test.js, the reads-before-batch equal) Fix (test only): record whether the chat held the keyboard at each layout read; expect 1 read, or 2 when the chat held it at the first read. A repeated plain measurement or a third read still fails. Product behaviour unchanged. --- .../src/tests/studyWorkspaceEntry.test.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/extension/src/tests/studyWorkspaceEntry.test.ts b/extension/src/tests/studyWorkspaceEntry.test.ts index 0bdbc8b4..153aebbd 100644 --- a/extension/src/tests/studyWorkspaceEntry.test.ts +++ b/extension/src/tests/studyWorkspaceEntry.test.ts @@ -2761,17 +2761,19 @@ test('a live entry from the Study layout arranges the application above the sour }) for (const besideChat of [false, true]) - // Value: protects=retirement before native measurement and one final arrangement measurement; fails_when=a disposed Context tab is measured or arrange repeats its initial read; why_new=Cycle 0045 SP-5 with PC-3.8, reconciled with dedicated App preparation; the tab's close held until the final arrangement began, so its wait is reached (Cycle 0050 F240); seam=passthrough native-command and arrange observers installed before the run starts, the final arrangement told by the context pane it retires (F154); no_claim=where the final arrangement runs, during preparation or at the explicit open + // Value: protects=retirement before native measurement and one final arrangement measurement; fails_when=a disposed Context tab is measured or arrange repeats its initial read while the chat does not hold the keyboard (a keyboard borrowed from the chat reads the layout once more within the same measurement); why_new=Cycle 0045 SP-5 with PC-3.8, reconciled with dedicated App preparation; the tab's close held until the final arrangement began, so its wait is reached (Cycle 0050 F240); seam=passthrough native-command and arrange observers installed before the run starts, the final arrangement told by the context pane it retires (F154); no_claim=where the final arrangement runs, during preparation or at the explicit open test(`live entry waits for retired Study tabs before measurement and measures its final arrangement once${besideChat ? ', returning the keyboard to chat' : ''}`, async () => { // Arrange: native tab retirement is delayed, and App preparation has its own separate measurements. const harness = await workspaceHarness() const executeCommand = harness.editor.commands.executeCommand, arrange = harness.reader['arrange'], readsWithRetiredTab: boolean[] = [], + chatKeyboardAtReads: boolean[] = [], arrangements: { retiresContextPane: boolean commands: string[] readsWithRetiredTab: boolean[] + chatKeyboardAtReads: boolean[] }[] = [] try { harness.useNativeLayout({ orientation: 0, groups: [{ size: 980 }] }) @@ -2782,6 +2784,8 @@ for (const besideChat of [false, true]) harness.holdDisposedPanelTabCloses() // Observe actual native effects without changing their arguments, responses or timing, from before the run starts: the live entry's final arrangement can run while the run prepares, on a loaded machine before the explicit open below (F154). harness.editor.commands.executeCommand = async (...args) => { + if (args[0] === 'vscode.getEditorLayout') + chatKeyboardAtReads.push(chat?.focused() === true) if (args[0] === 'vscode.getEditorLayout') readsWithRetiredTab.push( harness.groups.some((group) => @@ -2797,6 +2801,7 @@ for (const besideChat of [false, true]) harness.reader['arrange'] = async (...args): Promise => { const start = harness.commands.length, readsStart = readsWithRetiredTab.length + if (args[5] !== undefined) chat?.focusTab() // PROBE const arranged = arrange.apply(harness.reader, args) // The arrangement that retires the context pane runs up to its wait for the tab's close in this turn; the editor closes the tab in the next one. if (args[5] !== undefined) @@ -2809,6 +2814,7 @@ for (const besideChat of [false, true]) retiresContextPane: args[5] !== undefined, commands: harness.commands.slice(start), readsWithRetiredTab: readsWithRetiredTab.slice(readsStart), + chatKeyboardAtReads: chatKeyboardAtReads.slice(readsStart), }) } } @@ -2834,18 +2840,21 @@ for (const besideChat of [false, true]) (arrangement) => arrangement.retiresContextPane, ) assert.equal(finalArrangements.length, 1) - const { commands, readsWithRetiredTab: finalReads } = - finalArrangements[0]! + const { + commands, + readsWithRetiredTab: finalReads, + chatKeyboardAtReads: finalChatKeyboardAtReads, + } = finalArrangements[0]! assert.equal(finalReads.length > 0, true) assert.equal(finalReads.includes(true), false) const batch = commands.indexOf('runCommands') assert.notEqual(batch, -1) - assert.equal( - commands - .slice(0, batch) - .filter((command) => command === 'vscode.getEditorLayout').length, - 1, - ) + // The measurement beside the chat's editor part reads the layout once more after it borrowed the main editor's first group (readMainEditorGrid), when the keyboard was with the chat as the measurement began; that second read belongs to the same measurement. + const layoutReadsBeforeBatch = commands + .slice(0, batch) + .filter((command) => command === 'vscode.getEditorLayout').length + const chatHadKeyboardAtFirstRead = finalChatKeyboardAtReads[0] === true + assert.equal(layoutReadsBeforeBatch, chatHadKeyboardAtFirstRead ? 2 : 1) if (chat) { assert.equal(chat.focused(), true) assert.deepEqual( From 5b39b7678b7738d03cfbe5517fc5b97fbb276b58 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 07:59:51 +0900 Subject: [PATCH 09/31] fix: Cycle 0056 pt3: the saved test replay reader test waits for the reader's position writes before it reopens the history (task 30) --- extension/src/tests/studyWorkspace.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/extension/src/tests/studyWorkspace.test.ts b/extension/src/tests/studyWorkspace.test.ts index 490f4399..7b374746 100644 --- a/extension/src/tests/studyWorkspace.test.ts +++ b/extension/src/tests/studyWorkspace.test.ts @@ -106,7 +106,7 @@ test('an unreadable Node history folder is reported while the saved-study reader } }) -// Value: protects=a saved test replay opens in the Study reader beside browser studies, pages its events, sends each selected stop's recorded values and sealed source to the native debugger through the replay's one recorded-source slot (PR-B2, DS5; counts follow recorded-stop presentations, as the debugger also starts on the "Not recorded" thread), and shows in the Application pane exactly the recorded output up to the selected event; fails_when=the reader enters browser playback, the Application pane shows output recorded after the selected event or no recorded output document, or a reopened store loses the selected moment; why_new=PR-B1 moved the recorded output from the Study pane's block to the Application pane (Design 3, AC5); seam=the workspace harness's posted messages and native panels; no_claim=the document's pixels or its in-place updates in a real webview; why_changed=a move leaves the earlier moment for 300 ms before it commits (F385, F387), so the wait also requires the position not to be pending +// Value: protects=a saved test replay opens in the Study reader beside browser studies, pages its events, sends each selected stop's recorded values and sealed source to the native debugger through the replay's one recorded-source slot (PR-B2, DS5; counts follow recorded-stop presentations, as the debugger also starts on the "Not recorded" thread), and shows in the Application pane exactly the recorded output up to the selected event; fails_when=the reader enters browser playback, the Application pane shows output recorded after the selected event or no recorded output document, or a reopened store loses the selected moment; why_new=PR-B1 moved the recorded output from the Study pane's block to the Application pane (Design 3, AC5); seam=the workspace harness's posted messages and native panels; no_claim=the document's pixels or its in-place updates in a real webview; why_changed=a move leaves the earlier moment for 300 ms before it commits (F385, F387), so the wait also requires the position not to be pending; why_changed(task 30)=the reopen waited only for stop 25 on disk, which an earlier visit had already written, so a reader write of stop 24 landing late made the fresh store open at 24 (red 3/3 with that write held); it now also waits for the reader's position write loop to end test('saved Node history is selectable in the reader without entering browser playback', async () => { // Arrange const harness = await workspaceHarness() @@ -683,7 +683,12 @@ test('saved Node history is selectable in the reader without entering browser pl 'second stop is selected in the reader before reopening its history', READER_RESULT_BOUND_MS, ) - // The reader writes the chosen stop to its store after the commit; the new store reads it from disk, so the reopen waits for that write (F231). + // The reader writes each chosen stop to its store after the commit, never awaited and one write at a time (F197), so the disk can still hold an earlier visit's stop 25 while the writes of stops 24 and 26 run; a fresh store reads the last write, so the reopen waits for the reader's write loop to end and then for the chosen stop on disk (F231, task 30). + await waitUntil( + () => harness.reader['nodeHistoryPositionWrites'].size === 0, + 'the reader has written every chosen stop to its history store', + READER_RESULT_BOUND_MS, + ) await waitUntil( async () => (await store.readReaderPosition(header.studyId)) === 25, 'the chosen stop written to the history store', @@ -787,7 +792,12 @@ test('saved Node history is selectable in the reader without entering browser pl assert.doesNotMatch(shownTestOutput(harness), /Tests 1 passed/) const presentationsBeforeOutputRestore = recordedStopPresentations(harness).length - // As above: the output-only position reaches the disk after its commit (F231). + // As above: the output-only position reaches the disk after its commit (F231), and the reopen waits for the reader's write loop to end first, so a later queued write cannot land after the check (task 30). + await waitUntil( + () => harness.reader['nodeHistoryPositionWrites'].size === 0, + 'the reader has written the output-only position to its history store', + READER_RESULT_BOUND_MS, + ) await waitUntil( async () => (await store.readReaderPosition(header.studyId)) === 24, 'the output-only position written to the history store', From 9fd7e6cca87f8dcaf75cbdbaf04146b66df9b895 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 09:40:13 +0900 Subject: [PATCH 10/31] fix: Cycle 0056 pt3: after Close Study Workspace the Saved studies list names no study, so choosing the study the closed reader showed opens it (task 31) --- extension/README.md | 2 +- extension/scripts/study-bootstrap-qa.mjs | 40 +++++++++++++++++++ extension/src/studyWorkspace.ts | 2 + ...tudyWorkspaceTestReplayComposition.test.ts | 34 ++++++++++++++++ extension/src/ui/studyHtml.ts | 13 +++--- 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/extension/README.md b/extension/README.md index 0eaa5356..502b1b9a 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index f51cec27..80d39c0b 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -8684,6 +8684,46 @@ async function check(page, options, collected) { !promptOption.rendered, }) + // Arrange: the reader showed a saved study and was closed (Close Study Workspace keeps its selection, so the message still names it). + await library([shownStudy, otherStudy], shownStudy.id) + const whileReaderOpen = await removalControl() + await page.evaluate(() => { + window.sent = [] + }) + // Act: the reader closes, then the learner chooses the entry the closed reader showed. + await library([shownStudy, otherStudy], shownStudy.id, { + readerClosed: true, + }) + const whileReaderClosed = await removalControl() + await page.selectOption('#studies', shownStudy.id) + const openAskedAfterClose = await page.evaluate( + (studyId) => + window.sent.filter( + (message) => message.type === 'history' && message.studyId === studyId, + ).length, + shownStudy.id, + ) + // The host opens the reader again with that study. + await library([shownStudy, otherStudy], shownStudy.id) + const afterReaderOpened = await removalControl() + // Assert + // Value: protects=the learner choosing, right after Close Study Workspace, the saved study the closed reader showed and having it open (the list named it, so the choice fired no change event); fails_when=a closed reader's list names the study the reader showed, or the list stops naming the study once the reader is open again; why_new=Cycle 0056 task 31 (N56-F1, found in Cursor); seam=none; no_claim=the host's own open (studyWorkspace tests) (stand-ins: the Study messages the host posts) + cases.push({ + name: 'after Close Study Workspace the Saved studies list names no study, so choosing the study the closed reader showed opens it, and an open reader names its study again', + actual: { + whileReaderOpen, + whileReaderClosed, + openAskedAfterClose, + afterReaderOpened, + }, + passed: + whileReaderOpen.chosen === shownStudy.id && + whileReaderClosed.chosen === '' && + whileReaderClosed.shownText === 'Choose a saved study' && + openAskedAfterClose === 1 && + afterReaderOpened.chosen === shownStudy.id, + }) + // Arrange: a delete request the host refuses, shown under the button (A-L5, AD-14, DR-26). const sameRefusal = { status: 'refused', diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 7d59d546..3c2ac1cb 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -13778,6 +13778,8 @@ export class StudyWorkspace { elapsedMs: observation.elapsedMs, })), studies: studyCatalog, + // A closed reader keeps its selection for the next Open Study Workspace, but Saved studies must not name it (task 31). + ...(this.opened ? {} : { readerClosed: true as const }), studyRemoval: this.studyRemoval, ...(this.studyRemovalAvailability() === 'no-trash' ? { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 4ae933e0..e99df4a5 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -4076,3 +4076,37 @@ test('a failed move whose return to the last moment fails too does not say the l await harness.close() } }) + +// Value: protects=Saved studies naming no study while the reader is closed, so the learner can choose the study the closed reader showed and have it open, and naming the replay again once Open Study Workspace restores it; fails_when=a closed reader's Study message omits readerClosed, or an open reader sets it (Cycle 0056 task 31, N56-F1: the list kept naming the shown replay, so choosing it fired no change); why_new=the list rule lived in the page only, with no host signal for a closed reader; seam=the reader's inspected Study message; no_claim=the list's rendering (Reader DOM check study-bootstrap-qa) +test('a closed reader tells Saved studies that it is closed, and a reopened reader that shows its replay does not', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + const whileOpen = harness.reader.inspect().view?.readerClosed + + // Act + await harness.reader.close() + const afterClose = harness.reader.inspect().view?.readerClosed + await harness.reader.open() + const afterReopen = harness.reader.inspect().view + + // Assert + assert.deepEqual( + { + whileOpen, + afterClose, + afterReopen: afterReopen?.readerClosed, + replay: afterReopen?.nodeHistory?.header.studyId, + }, + { + whileOpen: undefined, + afterClose: true, + afterReopen: undefined, + replay: history.studyId, + }, + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 10505b1e..adb767aa 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -103,6 +103,8 @@ export interface StudyViewMessage { }[] studies: StudyCatalogEntry[] studyRemoval?: StudyRemovalView + /** Set while the Study reader is closed (before its first open and after Close Study Workspace): the Saved studies list names no entry, so every entry, the one the closed reader showed included, fires a `change` and opens on choice. */ + readerClosed?: true /** Set in a window without a Trash (a WSL remote window): a delete request moves nothing there and is answered with the study's files for removal by hand. */ removalWithoutTrash?: true /** The files and folders the host listed for removal by hand in a window without a Trash, by study: every list a pane asked for, so each pane shows the one of the entry it has chosen. */ @@ -1302,7 +1304,7 @@ function studyClient( const listedOrNone = (message: StudyViewMessage, studyId: string): string => message.studies.some((study) => study.id === studyId) ? studyId : '' /** - * Puts the Saved studies list on the entry this pane keeps chosen, else on the entry the host shows, and renders the delete control for it. + * Puts the Saved studies list on the entry this pane keeps chosen, else on the entry the host shows (none while the reader is closed), and renders the delete control for it. * Called on every Study message after the list's options are set, and when the host answers this pane's open request. * @param message - The current Study message. * @param fallback - The list's value to keep when neither this pane nor the host names an entry. @@ -1314,10 +1316,11 @@ function studyClient( ): void => { const studies = element('studies') if (!(studies instanceof HTMLSelectElement)) return - studies.value = listedOrNone( - message, - keptChoice() ?? shownStudyId(message) ?? fallback, - ) + // A closed reader keeps its selection for the next Open Study Workspace but shows nothing: naming it would make choosing it again fire no `change`. + const shownOrFallback = message.readerClosed + ? '' + : (shownStudyId(message) ?? fallback) + studies.value = listedOrNone(message, keptChoice() ?? shownOrFallback) renderStudyRemoval(message) } /** Follows the host's reply to an open request: when it names the request this pane still waits for, the open ended without showing the entry (refused, discarded or failed), so the list returns to the shown study and the delete button is released. A reply to an earlier request changes nothing. @example settleOpenRequest({ type: 'open-request-settled', openRequest: 3 }) */ From 506200e212bdafd7a93dd6e025b1f4b32135c349 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 09:40:18 +0900 Subject: [PATCH 11/31] fix: Cycle 0056 pt3: opening another test replay from a closed reader never draws the replay the closed reader showed in the Learning summary (task 32) --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 16 +- ...tudyWorkspaceTestReplayComposition.test.ts | 166 ++++++++++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/extension/README.md b/extension/README.md index 502b1b9a..4add19a1 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 3c2ac1cb..5fd56e28 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11942,7 +11942,7 @@ export class StudyWorkspace { return false if (entry !== this.nodeHistoryEntryRequest) return false try { - await this.enterSummaryCompositionForReplay(entry, overtaken) + await this.enterSummaryCompositionForReplay(studyId, entry, overtaken) } catch (error) { // A newer entry answers for the learner now; an entry whose focus wait its supersession aborted ends here quietly too. if (entry !== this.nodeHistoryEntryRequest) return false @@ -12034,13 +12034,16 @@ export class StudyWorkspace { /** * Opens the summary composition for a test replay that is about to open (Cycle 0051, DS-A): a closed reader opens, and the Live composition and the Study composition give way to it, so the Study pane closes and the Learning summary takes the chat's place; called by {@link openNodeHistoryFromHost}. * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). + * A closed reader keeps the replay it showed for the next Open Study Workspace ({@link close}); an entry for another replay retires that selection before {@link open} publishes, so the Learning summary never draws the closed reader's replay (its title, result and stop) while the chosen one opens (Cycle 0056 task 32). An entry for the same replay keeps it and shows it at once. * @throws What {@link open} throws; the caller tells it. + * @param studyId - The test replay's study id. * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}); an entry a newer one or a Return overtook while it waited for the work window reveals nothing. * @param overtaken - Aborts the waits for the work window and for the Learning summary view, in a standing composition and in {@link open}, when a newer entry, a Return or a Close supersedes the ticket, so the next entry's turn is not held for their bounds (Cycle 0052 tasks 40, 44 and 45). * @throws The abort's reason when the wait is overtaken; the caller ends quietly for a superseded ticket. - * @example await this.enterSummaryCompositionForReplay(entry, overtaken) + * @example await this.enterSummaryCompositionForReplay(studyId, entry, overtaken) */ private async enterSummaryCompositionForReplay( + studyId: string, entry: number, overtaken: AbortSignal, ): Promise { @@ -12059,6 +12062,15 @@ export class StudyWorkspace { } this.hostOpeningTestReplay = true try { + // A closed reader still holds the replay it showed: another replay's entry drops it before open() publishes the summary, and the retirement's native work is rechecked against the entry. + if ( + !this.opened && + this.selectedNodeHistory && + this.selectedNodeHistory.header.studyId !== studyId + ) { + await this.retireNodeHistorySelection() + overtaken.throwIfAborted() + } // The open's waits for the work window end too when a newer entry, a Return or a Close supersedes this one (Cycle 0052 task 44). await this.open(overtaken) } finally { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index e99df4a5..712e3663 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -4077,6 +4077,25 @@ test('a failed move whose return to the last moment fails too does not say the l } }) +/** + * Records the study ids of the test replays every Study message a view's webview receives from now on names, by wrapping its `postMessage`; the closed-reader open tests read it to prove that a view never draws another replay while a chosen one opens. + * @param view - The harness's Learning summary view. + * @returns The live list of each received Study message's shown test replay id (`'none'` for a message that names no test replay). + * @example const drawn = recordDrawnTestReplays(harness.summary) // => ['none', 'node_run-b'] + */ +function recordDrawnTestReplays(view: { + webview: { postMessage: (message: StudyViewMessage) => Promise } +}): string[] { + const drawn: string[] = [], + post = view.webview.postMessage.bind(view.webview) + view.webview.postMessage = async (message: StudyViewMessage) => { + if (message.type === 'study') + drawn.push(message.nodeHistory?.header.studyId ?? 'none') + return post(message) + } + return drawn +} + // Value: protects=Saved studies naming no study while the reader is closed, so the learner can choose the study the closed reader showed and have it open, and naming the replay again once Open Study Workspace restores it; fails_when=a closed reader's Study message omits readerClosed, or an open reader sets it (Cycle 0056 task 31, N56-F1: the list kept naming the shown replay, so choosing it fired no change); why_new=the list rule lived in the page only, with no host signal for a closed reader; seam=the reader's inspected Study message; no_claim=the list's rendering (Reader DOM check study-bootstrap-qa) test('a closed reader tells Saved studies that it is closed, and a reopened reader that shows its replay does not', async () => { // Arrange @@ -4110,3 +4129,150 @@ test('a closed reader tells Saved studies that it is closed, and a reopened read await harness.close() } }) + +// Value: protects=Open replay from the status item after Close never draws the replay the closed reader showed in the Learning summary while the chosen one opens; fails_when=the summary receives a Study message that names the closed reader's replay once the open starts (Cycle 0056 task 32, N56-F2: 1 to 2 frames of the earlier replay's title, result and stop beside "The recorded source is opening"); why_new=Cycle 0052 task 2 covered Return to Live, not an open from a closed reader that keeps its selection; seam=the harness's summary webview postMessage; no_claim=the native frames, which the N56 rerun checks +test('Open replay of another test replay after Close never draws the replay the closed reader showed in the Learning summary', async () => { + // Arrange + const harness = await workspaceHarness() + try { + // Saved before the first open, whose Retry storage / refresh lists both replays in Saved studies. + const chosenReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const drawn = recordDrawnTestReplays(harness.summary) + + // Act + await harness.reader.openLearningReplay(chosenReplay.studyId) + await landsOn(harness, 2, 'the chosen replay lands on its first stop') + + // Assert + await waitUntil( + () => drawn.at(-1) === chosenReplay.studyId, + 'the summary draws the chosen replay', + READER_RESULT_BOUND_MS, + ) + assert.equal(drawn.includes(closedReplay.studyId), false) + } finally { + await harness.close() + } +}) + +// Value: protects=a Saved studies choice of another test replay after Close never draws the replay the closed reader showed in the Learning summary while the chosen one opens; fails_when=the summary receives a Study message that names the closed reader's replay once the choice arrives (Cycle 0056 task 32, N56-F2); why_new=the status item's Open replay and a Saved studies choice enter the closed reader through the same entry but each is its own learner path; seam=the harness's summary webview postMessage and the Study pane's node-history message; no_claim=the native frames, which the N56 rerun checks +test('a Saved studies choice of another test replay after Close never draws the replay the closed reader showed in the Learning summary', async () => { + // Arrange + const harness = await workspaceHarness() + try { + // Saved before the first open, whose Retry storage / refresh lists both replays in Saved studies. + const chosenReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const drawn = recordDrawnTestReplays(harness.summary) + + // Act + await readerSeams(harness).receive( + { + type: 'node-history', + revision: harness.reader.inspect().view!.revision, + studyId: chosenReplay.studyId, + }, + 'context', + ) + await landsOn(harness, 2, 'the chosen replay lands on its first stop') + + // Assert + await waitUntil( + () => drawn.at(-1) === chosenReplay.studyId, + 'the summary draws the chosen replay', + READER_RESULT_BOUND_MS, + ) + assert.equal(drawn.includes(closedReplay.studyId), false) + } finally { + await harness.close() + } +}) + +// Value: protects=choosing again the test replay the closed reader showed still opens it at once, with the Learning summary drawing that replay; fails_when=retiring the closed reader's selection for another replay also retires the same replay's, or the summary never draws it (Cycle 0056 task 32); why_new=the fix retires a closed reader's selection only for another replay; seam=the harness's summary webview postMessage; no_claim=the native frames +test('Open replay of the test replay the closed reader showed draws that replay in the Learning summary', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const drawn = recordDrawnTestReplays(harness.summary) + + // Act + await harness.reader.openLearningReplay(closedReplay.studyId) + await landsOn(harness, 2, 'the replay lands on its first stop') + + // Assert + await waitUntil( + () => drawn.at(-1) === closedReplay.studyId, + 'the summary draws the replay again', + READER_RESULT_BOUND_MS, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a browser recording chosen after Close of a test replay never draws the test replay in the Learning summary while the recording opens; fails_when=the summary receives a Study message that names the closed reader's test replay once the recording's open starts (Cycle 0056 task 32, the browser side of N56-F2); why_new=the task's fix covers a test replay entering a closed reader, and the reverse entry needed its own proof; seam=recordedReaderHarness, the summary webview's postMessage; no_claim=the native frames, which the N56 rerun checks +test('a browser recording opened after Close of a test replay never draws the test replay in the Learning summary', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'the test replay lands on its first stop') + await harness.reader.close() + const drawn = recordDrawnTestReplays(harness.summary) + + // Act + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + + // Assert: the summary was published to, and none of it named the test replay. + assert.equal(drawn.length > 0, true) + assert.equal(drawn.includes(history.studyId), false) + } finally { + await harness.close() + } +}) + +// Value: protects=a test replay chosen after Close of a browser recording never draws the recording in the Learning summary while the replay opens; fails_when=the summary receives a Study message that carries the closed reader's recording once the test replay's open starts (Cycle 0056 task 32, the browser side of N56-F2); why_new=the task's fix covers a test replay closed before another test replay, and the reverse entry needed its own proof; seam=recordedReaderHarness, the summary webview's postMessage; no_claim=the native frames, which the N56 rerun checks +test('a test replay opened after Close of a browser recording never draws the recording in the Learning summary', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + await harness.reader.close() + harness.answerWarningDialogs(async () => 'Close browser replay') + const recordingsDrawn: (string | undefined)[] = [], + post = harness.summary.webview.postMessage.bind(harness.summary.webview) + harness.summary.webview.postMessage = async (message: StudyViewMessage) => { + if (message.type === 'study' && message.recording !== undefined) + recordingsDrawn.push(message.nodeHistory?.header.studyId) + return post(message) + } + + // Act + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'the test replay lands on its first stop') + + // Assert + assert.deepEqual(recordingsDrawn, []) + } finally { + await harness.close() + } +}) From 26a12edaf7aa58ce164542fc57bba21fced4ecc3 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 08:55:08 +0900 Subject: [PATCH 12/31] fix: Cycle 0056 pt3: Manage Workspace Trust runs the one trust command both editors have, and says where to find it when the editor rejects it (task 23) --- extension/src/constants.ts | 9 ++-- extension/src/tests/llmWikiView.test.ts | 41 +++++++++++++------ .../tests/resolveManageTrustCommand.test.ts | 25 ----------- extension/src/tests/utils/wikiUpdateWindow.ts | 3 -- .../src/utils/registerLlmWikiCommands.ts | 18 ++++---- .../src/utils/resolveManageTrustCommand.ts | 21 ---------- 6 files changed, 42 insertions(+), 75 deletions(-) delete mode 100644 extension/src/tests/resolveManageTrustCommand.test.ts delete mode 100644 extension/src/utils/resolveManageTrustCommand.ts diff --git a/extension/src/constants.ts b/extension/src/constants.ts index a60f2509..077329bb 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1030,11 +1030,8 @@ export const COPY_WIKI_RECOVERY_REQUEST_COMMAND = export const REVEAL_WIKI_FOLDER_COMMAND = 'codebook.revealWikiFolder' /** The button of the attention notice that copies its sentence. */ export const COPY_DETAILS_ACTION = 'Copy details' -/** The ids of the editor's command that manages workspace trust, in the order Codebook prefers them; the view runs the first one the editor lists (`workbench.trust.manage` is VS Code's own id; vscode-go calls `workbench.action.manageTrust`). Unverified in each host until the native trial. */ -export const MANAGE_WORKSPACE_TRUST_COMMAND_IDS = [ - 'workbench.trust.manage', - 'workbench.action.manageTrust', -] as const +/** The editor's command that manages workspace trust. Native trial N56 (2026-10-27): VS Code 1.142.0 and Cursor 3.27.12 both open their Workspace Trust editor with it; Cursor's Keyboard Shortcuts editor lists `workbench.trust.manage` and registers no `workbench.action.manageTrust`. */ +export const MANAGE_WORKSPACE_TRUST_COMMAND = 'workbench.trust.manage' /** What one inline action of an LLM Wiki view row is: the command it runs and the words of its title. The row function names an action by its key, the manifest contributes each command with a menu entry that tests `viewItem` for the key. */ export const LLM_WIKI_ROW_ACTIONS = { update: { command: UPDATE_DEEP_WIKI_COMMAND, title: 'Update LLM Wiki' }, @@ -1069,7 +1066,7 @@ export const LLM_WIKI_ROW_ACTIONS = { }, } as const -/** What the LLM Wiki view says when the editor lists none of the known ids of its Manage Workspace Trust command ({@link resolveManageTrustCommand}, run by {@link registerLlmWikiCommands}). */ +/** What the LLM Wiki view says when the editor rejects its Manage Workspace Trust command ({@link MANAGE_WORKSPACE_TRUST_COMMAND}, run by {@link registerLlmWikiCommands}). */ export const LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE = 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.' diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index 4f1bb38c..ac831d99 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -447,30 +447,47 @@ test('a window without a folder offers Open Folder and an untrusted folder offer ) }) -// Value: protects=the manage-trust id decision; fails_when=the action runs an id the editor does not list, or runs none and says nothing; no_claim=that the id exists in Cursor (native trial); seam=the stubbed editor's command list -test('Manage Workspace Trust runs the first known trust command the editor lists, and says where to find it when it lists none', async (context) => { +// Value: protects=Manage Workspace Trust in VS Code and Cursor (both list only workbench.trust.manage, N56 step 2); fails_when=the action asks the editor for its command list first and shows the notice instead of running the command because the list names neither id; why_new=the old lookup hid the command whenever getCommands did not list it; seam=the stubbed editor, whose command list holds neither trust id; no_claim=that the native editors open their trust editor (native trial) +test('Manage Workspace Trust runs workbench.trust.manage without consulting the editor’s command list', async (context) => { // Arrange - const { window: listed } = await openPublishedWiki(context, { - trusted: false, - extraEditorCommands: ['workbench.action.manageTrust'], - }) - const { window: unlisted } = await openPublishedWiki(context, { + const { window } = await openPublishedWiki(context, { trusted: false }) + + // Act + await window.runCommand('codebook.llmWiki.manageTrust') + + // Assert + assert.deepEqual( + [window.executedCommands, window.notices], + [['workbench.trust.manage'], []], + ) +}) + +// Value: protects=the learner knowing where to trust a folder in an editor without the command; fails_when=a rejected trust command leaves no notice or no cause in Output › Codebook; why_new=the old code never ran the command when it was not listed, so a rejection was unhandled; seam=the stubbed editor refusing workbench.trust.manage; no_claim=the wording of a native editor's own error +test('Manage Workspace Trust says where to find the command and records the cause when the editor rejects it', async (context) => { + // Arrange + const { window } = await openPublishedWiki(context, { trusted: false, + missingEditorCommands: ['workbench.trust.manage'], }) // Act - await listed.runCommand('codebook.llmWiki.manageTrust') - await unlisted.runCommand('codebook.llmWiki.manageTrust') + await window.runCommand('codebook.llmWiki.manageTrust') // Assert assert.deepEqual( - [listed.executedCommands, unlisted.executedCommands, unlisted.notices], [ - ['workbench.action.manageTrust'], - [], + window.notices, + window.outputLines.some( + (line) => + line.startsWith('error: ') && + line.includes("command 'workbench.trust.manage' not found"), + ), + ], + [ [ 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.', ], + true, ], ) }) diff --git a/extension/src/tests/resolveManageTrustCommand.test.ts b/extension/src/tests/resolveManageTrustCommand.test.ts deleted file mode 100644 index 95fd6e82..00000000 --- a/extension/src/tests/resolveManageTrustCommand.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict' -import { test } from 'node:test' - -import { resolveManageTrustCommand } from '../utils/resolveManageTrustCommand' - -// Value: protects=the manage-trust id decision (assumed ids workbench.trust.manage and workbench.action.manageTrust); fails_when=the first known id is not preferred, an id the editor does not list is returned, or an editor with none gets one; no_claim=that either id exists in Cursor (native trial); seam=the pure function -test('the manage-trust command is the first known id the editor lists, and none when it lists none', () => { - // Arrange - const both = ['x', 'workbench.action.manageTrust', 'workbench.trust.manage'] - const onlySecond = ['workbench.action.manageTrust'] - - // Act - const picks = [ - resolveManageTrustCommand(both), - resolveManageTrustCommand(onlySecond), - resolveManageTrustCommand(['other']), - ] - - // Assert - assert.deepEqual(picks, [ - 'workbench.trust.manage', - 'workbench.action.manageTrust', - undefined, - ]) -}) diff --git a/extension/src/tests/utils/wikiUpdateWindow.ts b/extension/src/tests/utils/wikiUpdateWindow.ts index 73cbc187..2042239c 100644 --- a/extension/src/tests/utils/wikiUpdateWindow.ts +++ b/extension/src/tests/utils/wikiUpdateWindow.ts @@ -227,8 +227,6 @@ export async function openWikiUpdateWindow(options: { noFolder?: boolean /** Keeps these steps open from the window's activation on, so a test sees the state before the first read ends; `hold(step)` then returns the same hold. */ holdAtOpen?: HeldUpdateStep[] - /** Commands the editor lists besides the chat's (for example the id of its manage-trust command). */ - extraEditorCommands?: string[] /** Makes the editor refuse the file watcher of the LLM Wiki view with this message. */ wikiWatcherFails?: string /** Makes this many removals of the window's own run marker throw `EBUSY` before they work, as a scanner holding the file does on Windows; each is recorded in `markerChanges` as `threw`. */ @@ -565,7 +563,6 @@ export async function openWikiUpdateWindow(options: { 'workbench.action.chat.newChat', 'workbench.action.chat.cancel', 'workbench.action.chat.removeAllPendingRequests', - ...(options.extraEditorCommands ?? []), ].filter( (command) => !options.missingEditorCommands?.includes(command), ), diff --git a/extension/src/utils/registerLlmWikiCommands.ts b/extension/src/utils/registerLlmWikiCommands.ts index ff396f37..57e54eaa 100644 --- a/extension/src/utils/registerLlmWikiCommands.ts +++ b/extension/src/utils/registerLlmWikiCommands.ts @@ -9,6 +9,7 @@ import { llmWikiFolderLocationFailedNotice, LLM_WIKI_RECOVERY_REQUEST_COPIED_NOTICE, LLM_WIKI_ROW_ACTIONS, + MANAGE_WORKSPACE_TRUST_COMMAND, OPEN_CHAT_COMMAND, OPEN_FOLDER_COMMAND, OPEN_SETTINGS_COMMAND, @@ -22,7 +23,6 @@ import { import { describeUnrevealableWikiFolder } from './describeUnrevealableWikiFolder' import type { LlmWikiView } from './llmWikiView' -import { resolveManageTrustCommand } from './resolveManageTrustCommand' import { thrownMessage } from './thrownMessage' import { wikiFolderKindOf } from './wikiFolderKindOf' import { wikiOutputLabel } from './wikiOutputLabel' @@ -79,22 +79,24 @@ async function showRowDetail( } /** - * Runs the editor's command that manages workspace trust, or says where to find it when the editor lists none of the known ids. + * Runs the editor's Manage Workspace Trust command, or says where to find it when the editor rejects it. + * + * Trigger: the Manage Workspace Trust action of the view's trust row ({@link registerLlmWikiCommands}). * @param options - The window's closures. - * @returns Nothing. + * @returns Nothing; a rejection is recorded in Output › Codebook and the learner gets the Command Palette notice. * @example await manageTrust(options) */ async function manageTrust(options: LlmWikiCommandOptions): Promise { - const available = await options.editor.commands.getCommands(true) - const commandId = resolveManageTrustCommand(available) - if (commandId === undefined) { + try { + await options.editor.commands.executeCommand(MANAGE_WORKSPACE_TRUST_COMMAND) + } catch (cause) { + // An editor without the command rejects; the learner still learns where to trust the folder. + options.recordFailure('Manage Workspace Trust could not run', cause) void options.showNotice( 'information', LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE, ) - return } - await options.editor.commands.executeCommand(commandId) } /** diff --git a/extension/src/utils/resolveManageTrustCommand.ts b/extension/src/utils/resolveManageTrustCommand.ts deleted file mode 100644 index b2223d0e..00000000 --- a/extension/src/utils/resolveManageTrustCommand.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { MANAGE_WORKSPACE_TRUST_COMMAND_IDS } from '../constants' - -/** - * Picks the id of the editor's command that manages workspace trust from the commands the editor lists. - * - * Why: the command's id is not documented and may differ between VS Code and Cursor ({@link MANAGE_WORKSPACE_TRUST_COMMAND_IDS}); the first listed one is run, and none lets the caller say where to find it. - * Trigger: the LLM Wiki view's Manage Workspace Trust action. - * - * @param availableCommands - The ids `vscode.commands.getCommands(true)` lists. - * @returns The first known id the editor lists; `undefined` when it lists none. - * @example - * resolveManageTrustCommand(['workbench.trust.manage', 'other']) // => 'workbench.trust.manage' - * resolveManageTrustCommand(['other']) // => undefined - */ -export function resolveManageTrustCommand( - availableCommands: readonly string[], -): string | undefined { - return MANAGE_WORKSPACE_TRUST_COMMAND_IDS.find((commandId) => - availableCommands.includes(commandId), - ) -} From e816e184ff9f46818cdb7c3271275ea471495176 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 08:59:49 +0900 Subject: [PATCH 13/31] fix: Cycle 0056 pt3: the LLM Wiki view follows a deleted or moved Wiki folder and shows the no-Wiki welcome without Refresh (task 33) --- extension/README.md | 1 + .../src/tests/chooseWikiWatchFolder.test.ts | 17 ++++ extension/src/tests/llmWikiView.test.ts | 77 ++++++++++++++++--- extension/src/utils/chooseWikiWatchFolder.ts | 24 ++++-- extension/src/utils/llmWikiView.ts | 2 +- 5 files changed, 101 insertions(+), 20 deletions(-) diff --git a/extension/README.md b/extension/README.md index 4add19a1..c5a51cf2 100644 --- a/extension/README.md +++ b/extension/README.md @@ -245,6 +245,7 @@ For tools: `GET /api/update-status` answers the current state as JSON (` The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site of the opened project stands and the next step; it sits below the **Repository Wiki** view, which lists repository articles, not the generated site. Its rows come and go with the facts they show, in a fixed order, and the tooltip of a row has its full text: - With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. +- The view follows its Wiki folder on disk: creating the folder, or deleting or moving it or any folder above it inside your project (for example `rm -rf .codebook`), refreshes the rows within a second, and a deleted Wiki brings back the welcome without **Refresh**. A Wiki folder outside the project is watched from its own parent folder only, so deleting that parent shows after **Refresh** or the next time the view is shown (Codebook never watches your home folder or a drive root). - In a folder with no Wiki and no run the view shows a welcome with a **Generate LLM Wiki** button, what runs it in your editor (Copilot Chat in VS Code, `cursor-agent` in Cursor) and the output folder with a **Change location** link (`The Wiki is written to .codebook/wiki unless the setting codebook.wikiOutputDirectory names another folder.`). A click on the **Output** row opens the same setting. - **` Wiki`** is the published Wiki. A click opens it in the browser. Its description is the freshness label of the reader page (`Wiki source current`, `Source changed · Update available`, `Wiki freshness unknown: …`, or `Checking freshness…` while the check runs), and its tooltip names the generation: `Generated from on `, `Generated on (no Git revision)` in a folder without Git, or `Generated from ` when the date cannot be read; when the Wiki records no Git revision, the tooltip also says in full that added files are not checked, which the description may cut off in a narrow sidebar. **Update LLM Wiki** is its action while no run shows. The row stays while a run works, so you can read the Wiki during an update. - **`Generating…` or `Updating…`** is this window's run. In VS Code it reads `Waiting for the chat to start` and then `In chat · min of `, with the actions **Stop Watching** (an eye-closed icon), **Open Chat** (the chat that waits for an approval or never started is one click away) and **Show Output**; the message above the tree says that Stop watching does not stop the chat. A failed generation's row offers **Try Again** (a restart icon). Inline action names are in Title Case (**Stop Watching**, **Change Location**, **Open Chat**, **Open Folder**, **Show Output**, **Try Again**, **Copy Recovery Request**); **Cancel** keeps the stop icon because it stops the agent. In Cursor it reads `cursor-agent · min of ` with **Cancel** and **Show Output**. The description is read again every 30 seconds. If the start time cannot be read, the row shows no minutes (`In chat` or `cursor-agent`) rather than a wrong number. diff --git a/extension/src/tests/chooseWikiWatchFolder.test.ts b/extension/src/tests/chooseWikiWatchFolder.test.ts index 3aa297e8..fd95a5d3 100644 --- a/extension/src/tests/chooseWikiWatchFolder.test.ts +++ b/extension/src/tests/chooseWikiWatchFolder.test.ts @@ -23,6 +23,23 @@ test('a missing .codebook inside the project is watched from the project root', assert.equal(folder, '/work/repo') }) +// Value: protects=N56-F3, a deleted .codebook being seen; fails_when=the watcher stands on .codebook (or a deeper folder) once it exists, where deleting that folder is an event of the watched folder itself and the editor reports none; why_new=the old choice returned the nearest existing folder; no_claim=the editor's watcher; seam=the pure function +test('an existing .codebook inside the project is still watched from the project root', () => { + // Arrange + const choice = { + projectRoot: '/work/repo', + outputDirectory: '/work/repo/.codebook/wiki', + nearestExistingFolder: '/work/repo/.codebook', + homeDirectory: HOME, + } + + // Act + const folder = chooseWikiWatchFolder(choice) + + // Assert + assert.equal(folder, '/work/repo') +}) + // Value: protects=the editor from a recursive watch of the home folder or the root; fails_when=an output outside the project whose parent does not exist is watched from the nearest existing ancestor, here the home folder; no_claim=what the view shows instead (host test); seam=the pure function test('an output outside the project whose parent is missing gets no watcher, even when an ancestor exists', () => { // Arrange diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index ac831d99..6e3f5dce 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, @@ -551,7 +552,7 @@ test('a manifest created in the output folder shows the Wiki row after the watch ) }) -// Value: protects=AC 2 (the trailing debounce); fails_when=a burst of events reads the state once per event (more than the burst's read and the one follow-up read of the watcher's move), or the last event of the burst is dropped; no_claim=the 1 second value (a constant); seam=five events 10 ms apart, with the debounce shortened to 40 ms +// Value: protects=AC 2 (the trailing debounce); fails_when=a burst of events reads the state once per event (more than the burst's one read), or the last event of the burst is dropped; no_claim=the 1 second value (a constant); seam=five events 10 ms apart, with the debounce shortened to 40 ms test('five file events inside the debounce become one read that shows the last state', async (context) => { // Arrange const project = await openEmptyProject(context) @@ -569,8 +570,8 @@ test('five file events inside the debounce become one read that shows the last s await project.llmWiki.whenIdle() // Assert - // Two reads: the burst's one, and the single follow-up read that the watcher's move from the project to .codebook adds once the Wiki's folders exist. - assert.equal(project.looks.publishTokenReads - readsAtStart, 2) + // One read: the burst's. The watcher stays on the project root, so the Wiki's folders appearing moves it no more and adds no follow-up read. + assert.equal(project.looks.publishTokenReads - readsAtStart, 1) }) // Value: protects=AC 2 (refresh on the setting and on visibility); fails_when=changing codebook.wikiOutputDirectory or showing the view again does not read the state, or the watcher stays on the old folder; no_claim=VS Code's settings UI; seam=the stubbed configuration event and visibility event @@ -603,7 +604,7 @@ test('changing the output setting and showing the view again read the state agai watchedAfterTheSetting?.pattern, window.llmWiki.contextKeys[STATE_KEY], ], - [true, join(wiki.projectRoot, 'docs'), globFor([], 'wiki'), 'none'], + [true, wiki.projectRoot, globFor(['docs'], 'wiki'), 'none'], ) }) @@ -661,8 +662,8 @@ test('an output folder outside the project shows its Wiki with the absolute fold ) }) -// Value: protects=AC 2 (a missing output parent) and Eng decision 78; fails_when=the watcher stands on a folder that does not exist, or is not made again on the folder a run creates; no_claim=VS Code's watcher on the root; seam=a Cursor generation whose agent waits -test('in a fresh project the watcher stands on the project and moves to .codebook once a run makes it', async (context) => { +// Value: protects=AC 2 (a missing output parent), Eng decision 78 and N56-F3; fails_when=the watcher stands on a folder that does not exist, or moves down onto .codebook once a run makes it, where a delete of .codebook would be unseen; why_changed=the watcher now stays on the project root instead of moving to .codebook (N56 step 2 saw a deleted .codebook unnoticed); no_claim=VS Code's watcher on the root; seam=a Cursor generation whose agent waits +test('in a fresh project the watcher stands on the project and stays there once a run makes .codebook', async (context) => { // Arrange const project = await openEmptyProject(context, { cursor: true, @@ -672,29 +673,36 @@ test('in a fresh project the watcher stands on the project and moves to .codeboo ), }) const watchedAtStart = project.watchedPatterns().at(-1) + const watcherCountAtStart = project.watchedPatterns().length // Act void project.runCommand(GENERATE_COMMAND) await untilRow(project, 'run') await waitUntil( - () => project.watchedPatterns().at(-1)?.base !== watchedAtStart?.base, - 'the watcher on .codebook', + () => existsSync(join(project.projectRoot, '.codebook')), + 'the .codebook folder the run makes', 6_000, ) + await project.runCommand('codebook.refreshLlmWiki') await project.runCommand('codebook.llmWiki.cancelRun') // Assert assert.deepEqual( - [watchedAtStart, project.watchedPatterns().at(-1)], + [ + watchedAtStart, + project.watchedPatterns().at(-1), + project.watchedPatterns().length - watcherCountAtStart, + ], [ { base: project.projectRoot, pattern: globFor(['.codebook'], 'wiki'), }, { - base: join(project.projectRoot, '.codebook'), - pattern: globFor([], 'wiki'), + base: project.projectRoot, + pattern: globFor(['.codebook'], 'wiki'), }, + 0, ], ) }) @@ -1845,6 +1853,53 @@ test('a delete event of the output folder alone, for a deleted or a renamed Wiki ) }) +// Value: protects=N56-F3, the view following a deleted .codebook folder; fails_when=the watcher stands on .codebook itself, so deleting that folder matches no planned path and the view keeps showing the deleted Wiki until Refresh; why_new=N56 step 2 saw the deleted Wiki's row for 40+ s; seam=the fake file watching, which like the editor reports nothing for the watched folder itself; no_claim=the native editor's event delivery for a recursive delete (native recheck) +test('deleting the .codebook folder that holds the Wiki brings back the no-Wiki welcome without a Refresh', async (context) => { + // Arrange + const { wiki, window } = await openPublishedWiki(context) + const codebookFolder = join(wiki.projectRoot, '.codebook') + + // Act + await rm(codebookFolder, { recursive: true }) + window.reportFileEvent('delete', codebookFolder) + await untilRow(window, 'wiki', false) + + // Assert + assert.deepEqual( + [window.llmWiki.contextKeys[STATE_KEY], idsOf(window)], + ['none', []], + ) +}) + +// Value: protects=N56-F3 for an output below several folders; fails_when=the watcher stands on the output's parent, so deleting a folder between the project root and the output (or the watched one) is not seen; why_new=same defect as the .codebook case for a nested output setting; seam=the fake file watching with the setting docs/site/wiki; no_claim=the native editor's event delivery for a recursive delete (native recheck) +test('deleting a folder between the project root and a nested Wiki output brings back the no-Wiki welcome without a Refresh', async (context) => { + // Arrange + const wiki = await publishedWikiFixture('docs/site/wiki') + const window = await openWikiUpdateWindow({ + constants: FAST, + projectRoot: wiki.projectRoot, + outputSetting: 'docs/site/wiki', + openReader: false, + }) + context.after(async () => { + await window.close() + await wiki.cleanup() + }) + await untilFirstRead(window) + const siteFolder = join(wiki.projectRoot, 'docs', 'site') + + // Act + await rm(siteFolder, { recursive: true }) + window.reportFileEvent('delete', siteFolder) + await untilRow(window, 'wiki', false) + + // Assert + assert.deepEqual( + [window.llmWiki.contextKeys[STATE_KEY], idsOf(window)], + ['none', []], + ) +}) + /** Writes a marker like the one a window of an earlier version left, naming a process that exists (pid 1), with the given reservation times. */ async function writeSpoofedReservation( markerPath: string, diff --git a/extension/src/utils/chooseWikiWatchFolder.ts b/extension/src/utils/chooseWikiWatchFolder.ts index 7deb69eb..a809d22a 100644 --- a/extension/src/utils/chooseWikiWatchFolder.ts +++ b/extension/src/utils/chooseWikiWatchFolder.ts @@ -20,7 +20,7 @@ type WikiWatchChoice = { * Why: walking up from a Wiki output whose parents do not exist yet could end at the home folder or the file-system root, and a recursive watch there costs the editor far more than the view is worth. * Trigger: {@link createLlmWikiView} at each read that shows a ready state. * - * Inside the project the watcher never stands above the project root; for an output outside it, only on the output's own parent when that exists. The home folder and a file-system root are never watched. + * Inside the project the watcher stands on the project root, so a delete or move of any folder from the root down to the output (the folder holding the Wiki included) is reported; the editor reports nothing for the watched folder itself. For an output outside the project it stands only on the output's own parent when that exists, so deleting that parent is not seen there: its ancestor could be the home folder or a root, which are never watched. When the project root is the home folder or a root, the nearest existing folder at or below it is used instead. * * @param choice - The project, the output, the nearest existing folder and the home folder. * @returns The folder to watch; `undefined` for the no-watcher fallback (the view then refreshes on visibility and its Refresh action only). @@ -44,14 +44,22 @@ export function chooseWikiWatchFolder( ) } const isInsideProject = isAtOrBelow(projectRoot, outputDirectory) - // Inside the project the nearest folder is the project root at the highest; outside, only the output's own parent counts. + /** Whether a folder is a file-system root or the home folder, which are never watched. */ + const isTooBroad = (folder: string): boolean => + pathApi.dirname(folder) === folder || + (homeDirectory !== '' && pathApi.relative(homeDirectory, folder) === '') + // Inside the project the watcher stands on the project root, so deleting the folder the Wiki lies in (.codebook, or any folder down to the output) is an event below the watched folder; a watcher on that folder itself hears nothing when it is deleted. + if ( + isInsideProject && + isAtOrBelow(projectRoot, nearestExistingFolder) && + !isTooBroad(projectRoot) + ) + return projectRoot + // Otherwise the nearest folder is the project root at the highest inside the project; outside, only the output's own parent counts. const isAcceptable = isInsideProject ? isAtOrBelow(projectRoot, nearestExistingFolder) : nearestExistingFolder === pathApi.dirname(outputDirectory) - const isRoot = - pathApi.dirname(nearestExistingFolder) === nearestExistingFolder - const isHome = - homeDirectory !== '' && - pathApi.relative(homeDirectory, nearestExistingFolder) === '' - return isAcceptable && !isRoot && !isHome ? nearestExistingFolder : undefined + return isAcceptable && !isTooBroad(nearestExistingFolder) + ? nearestExistingFolder + : undefined } diff --git a/extension/src/utils/llmWikiView.ts b/extension/src/utils/llmWikiView.ts index 9a7bb093..265426f1 100644 --- a/extension/src/utils/llmWikiView.ts +++ b/extension/src/utils/llmWikiView.ts @@ -349,7 +349,7 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { } /** - * Puts the watcher on the nearest existing folder of the output, and makes it again when that folder or the output changed. + * Puts the watcher on the folder {@link chooseWikiWatchFolder} picks (the project root for an output inside the project, so a deleted Wiki folder is seen), and makes it again when that folder or the output changed. * @param readyFacts - The facts just read. * @returns Whether a new watcher now stands, so the caller reads once more: an event between the read and the registration is in neither. */ From 02acefaabc1fdf2daf9cee386c4ba43706b306bf Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 09:17:25 +0900 Subject: [PATCH 14/31] fix: Cycle 0056 pt3: the LLM Wiki view's message and rows change in one emission of the editor's debounce at a run's end (task 16) --- extension/src/tests/llmWikiView.test.ts | 12 ++-- .../src/tests/llmWikiViewReadLoop.test.ts | 41 +++++++++++-- .../src/tests/utils/fakeLlmWikiEditor.ts | 61 ++++++++++++++++++- extension/src/utils/llmWikiView.ts | 30 +++------ 4 files changed, 112 insertions(+), 32 deletions(-) diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index 6e3f5dce..4dfbfc84 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -1249,8 +1249,8 @@ test('a collapsed view runs no freshness check for a manifest event and checks o ) }) -// Value: protects=N55-F3 (the message and the rows change in one render); fails_when=the view writes the tree message when it sets its model, before the editor reads the new rows, so the message shows in one frame and the rows in another; no_claim=the editor's frames (N56 step 4); seam=the fake tree's render log of change events, root reads and message writes -test('the message changes only when the tree reads the new rows, not when the change event fires', async (context) => { +// Value: protects=N55-F3 and task 16 (the message and the rows change in one emission of the extension host's debounce); fails_when=the view writes the new message apart from the tree change (before it with no window open: N55-F3; in getChildren after the leading change: about 200 ms late, N56), or omits the same-value write that opens the debounce window; why_changed=the message is written in the same tick as the change event (old message again, new message, change) instead of when the tree reads the rows; no_claim=the editor's frames and the debounce itself (the read-loop test models it; N56 step 4); seam=the harness's render log of change events and message writes +test('a changed message is written right before the tree change, after a same-value write of the old message', async (context) => { // Arrange const project = await openEmptyProject(context, { chatReply: () => new Promise(() => {}), @@ -1270,10 +1270,14 @@ test('the message changes only when the tree reads the new rows, not when the ch const log = project.llmWiki.renderLog() const writeIndex = log.indexOf(`message:${stoppedMessage}`) assert.deepEqual( - [project.llmWiki.message(), log.slice(writeIndex - 2, writeIndex + 1)], + [project.llmWiki.message(), log.slice(writeIndex - 1, writeIndex + 2)], [ stoppedMessage, - [`change:${watchingMessage}`, 'getChildren', `message:${stoppedMessage}`], + [ + `message:${watchingMessage}`, + `message:${stoppedMessage}`, + `change:${stoppedMessage}`, + ], ], ) }) diff --git a/extension/src/tests/llmWikiViewReadLoop.test.ts b/extension/src/tests/llmWikiViewReadLoop.test.ts index b7e26f8b..ce174f8b 100644 --- a/extension/src/tests/llmWikiViewReadLoop.test.ts +++ b/extension/src/tests/llmWikiViewReadLoop.test.ts @@ -1150,8 +1150,36 @@ test('showing the view again with a read that still fails restarts the poll acro assert.equal(reads - readsAfterShown, 1) }) -// Value: protects=SM2-7 (message cleared x getChildren) and N55-F3; fails_when=a state without a message writes the cleared message before the tree reads the new rows, or never clears the old one; no_claim=the editor's frames; seam=the fake tree's render log -test('a state without a message clears the message when the tree reads its rows, after getChildren', async (context) => { +// Value: protects=task 16 (N56: the message lagged the rows by about 200 ms at a run's end); fails_when=the message goes out in a later emission than the rows (written in getChildren after the leading tree change) or before them, so the editor paints rows under the old message or the new message over the old rows; why_new=the extension host sends a leading tree change alone and merges the rest for 200 ms, which the old message write in getChildren fell into; seam=the fake editor's model of ExtHostTreeView's leading 200 ms debounce; no_claim=the native editor's paint (N56 step 4) +test('a run ending changes the message and the rows in the one emission of the extension host’s debounce', async (context) => { + // Arrange + context.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }) + let isRunning = true + const { fake, view } = openView( + context, + { readFacts: async () => (isRunning ? runningFacts() : readyFacts()) }, + { modelsExtensionHostDebounce: true }, + ) + await view.whenIdle() + context.mock.timers.tick(200) + const emissionsBeforeTheEnd = fake.emissions().length + isRunning = false + + // Act + await view.refresh() + context.mock.timers.tick(200) + + // Assert + assert.deepEqual(fake.emissions().slice(emissionsBeforeTheEnd), [ + // The leading emission: the same-value write of the old message, which changes nothing on screen. + { hasMessage: true, message: WATCHING_MESSAGE, refreshesRoot: false }, + // The trailing emission: the cleared message and the root refresh together. + { hasMessage: true, message: undefined, refreshesRoot: true }, + ]) +}) + +// Value: protects=SM2-7 (message cleared x tree change) and task 16; fails_when=a state without a message never clears the old one, or clears it in getChildren (after the leading tree change, so the extension host sends it about 200 ms after the rows); why_changed=the message is no longer written when the tree reads its rows: show writes the old message again, then the new one, then fires the tree change; no_claim=the editor's frames; seam=the fake tree's render log +test('a state without a message clears the message in the same tick as the tree change, before the tree reads its rows', async (context) => { // Arrange let isRunning = true const { fake, view } = openView(context, { @@ -1167,10 +1195,12 @@ test('a state without a message clears the message when the tree reads its rows, // Assert assert.deepEqual(fake.renderLog(), [ - 'getChildren', + 'message:undefined', `message:${WATCHING_MESSAGE}`, 'getChildren', + `message:${WATCHING_MESSAGE}`, 'message:undefined', + 'getChildren', ]) }) @@ -1344,7 +1374,7 @@ test('a freshness check that keeps failing is recorded once in Output, and again ]) }) -// Value: protects=F8 (no write on a disposed tree view); fails_when=a root read after dispose writes the message on the disposed tree view; no_claim=the message before dispose; seam=a run row's message set on the model but not yet read by the tree, then dispose, then a root read +// Value: protects=F8 (no write on a disposed tree view); fails_when=a root read after dispose writes a message on the disposed tree view; why_changed=the message is written by show, so the guard is that a read after dispose changes nothing; no_claim=the message before dispose; seam=a run row's message shown, then dispose, then a root read test('a root read after dispose writes no message on the tree view', async (context) => { // Arrange const { fake, view } = openView(context, { @@ -1352,6 +1382,7 @@ test('a root read after dispose writes no message on the tree view', async (cont }) await view.whenIdle() view.dispose() + const logAtDispose = fake.renderLog() // Act fake.rows() @@ -1359,6 +1390,6 @@ test('a root read after dispose writes no message on the tree view', async (cont // Assert assert.deepEqual( [fake.renderLog(), fake.message()], - [['getChildren'], undefined], + [[...logAtDispose, 'getChildren'], WATCHING_MESSAGE], ) }) diff --git a/extension/src/tests/utils/fakeLlmWikiEditor.ts b/extension/src/tests/utils/fakeLlmWikiEditor.ts index 61066968..69d23df7 100644 --- a/extension/src/tests/utils/fakeLlmWikiEditor.ts +++ b/extension/src/tests/utils/fakeLlmWikiEditor.ts @@ -25,9 +25,56 @@ export type FakeTreeRow = { * fake.rows().map((row) => row.id) // => ['wiki', 'output'] */ export function createFakeLlmWikiEditor( - fakeOptions: { readsRootInsideCreateTreeView?: boolean } = {}, + fakeOptions: { + readsRootInsideCreateTreeView?: boolean + /** Models the extension host's `ExtHostTreeView`: tree changes and message writes feed one leading 200 ms debounce whose emission sends the message, then refreshes the root (`emissions()` records each). Uses `setTimeout`, so a test enables mock timers and ticks 200 ms. */ + modelsExtensionHostDebounce?: boolean + } = {}, ) { const renderLog: string[] = [] + /** What each emission of the modelled extension host's debounce carried. */ + const emissions: Array<{ + hasMessage: boolean + message: string | undefined + refreshesRoot: boolean + }> = [] + /** What the modelled debounce has merged since its last emission. */ + let pending = { hasMessage: false, refreshesRoot: false } + let debounceHandle: NodeJS.Timeout | undefined + let debounceCalls = 0 + const emit = (carried: typeof pending): void => { + emissions.push({ + hasMessage: carried.hasMessage, + message: carried.hasMessage ? shownMessage : undefined, + refreshesRoot: carried.refreshesRoot, + }) + // The handler sends the message first, then refreshes the root (`$getChildren`). + if (carried.refreshesRoot) provider?.getChildren() + } + /** `Event.debounce(merge, 200, true)`: the first event of a quiet period is emitted at once with its own data, later ones merge into one emission when 200 ms pass without an event. */ + const feedDebounce = (event: Partial): void => { + if (!fakeOptions.modelsExtensionHostDebounce) return + debounceCalls += 1 + pending = { + hasMessage: pending.hasMessage || event.hasMessage === true, + refreshesRoot: pending.refreshesRoot || event.refreshesRoot === true, + } + if (debounceHandle === undefined) { + const leading = pending + pending = { hasMessage: false, refreshesRoot: false } + emit(leading) + } + clearTimeout(debounceHandle) + debounceHandle = setTimeout(() => { + const trailing = pending + const didMerge = debounceCalls > 1 + pending = { hasMessage: false, refreshesRoot: false } + debounceHandle = undefined + debounceCalls = 0 + if (didMerge) emit(trailing) + }, 200) + debounceHandle.unref() + } let shownMessage: string | undefined const files = createFakeFileWatching() const contextKeys: Record = {} @@ -82,7 +129,11 @@ export function createFakeLlmWikiEditor( window: { createTreeView: ( _id: string, - treeOptions: { treeDataProvider: NonNullable }, + treeOptions: { + treeDataProvider: NonNullable & { + onDidChangeTreeData?: (listener: () => void) => unknown + } + }, ) => { const viewProvider = treeOptions.treeDataProvider provider = { @@ -92,6 +143,9 @@ export function createFakeLlmWikiEditor( }, getTreeItem: (id) => viewProvider.getTreeItem(id), } + viewProvider.onDidChangeTreeData?.(() => + feedDebounce({ refreshesRoot: true }), + ) if (fakeOptions.readsRootInsideCreateTreeView) provider.getChildren() return { get message(): string | undefined { @@ -100,6 +154,7 @@ export function createFakeLlmWikiEditor( set message(text: string | undefined) { shownMessage = text renderLog.push(`message:${text}`) + feedDebounce({ hasMessage: true }) }, get visible(): boolean { return isViewShown @@ -135,6 +190,8 @@ export function createFakeLlmWikiEditor( }), contextKeys, files, + /** The emissions of the modelled extension host's debounce so far ({@link createFakeLlmWikiEditor} option `modelsExtensionHostDebounce`). */ + emissions: () => emissions.map((emission) => ({ ...emission })), /** The message the tree view shows now. */ message: (): string | undefined => shownMessage, /** What the fake tree did, in order: `getChildren` for each root read and `message:` for each message write. */ diff --git a/extension/src/utils/llmWikiView.ts b/extension/src/utils/llmWikiView.ts index 265426f1..56d114ee 100644 --- a/extension/src/utils/llmWikiView.ts +++ b/extension/src/utils/llmWikiView.ts @@ -154,9 +154,7 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { /** The key ({@link failureKeyOf}) of the last failure recorded in Output, per kind: `read` is cleared when a good model is shown, `freshness` when a freshness check succeeds, so a failure that persists across poll ticks is recorded once and a different one is recorded too. */ const recordedFailureKeys: Record<'read' | 'freshness', string | undefined> = { read: undefined, freshness: undefined } - /** Whether the tree view handle exists; an editor may read the provider while it is being created. */ - let isTreeViewCreated = false - /** The message the tree view shows now; the next one is applied when the tree reads the new rows. */ + /** The message the tree view shows now; {@link show} writes a changed one together with the tree change. */ let appliedMessage: string | undefined let pollTimer: NodeJS.Timeout | undefined let isDisposed = false @@ -179,17 +177,6 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { onDidChangeTreeData: treeChanges.event, getChildren: (element) => { if (element) return [] - // The message follows the rows in one render: it is written here, when the tree reads the new model's root children after the change event, not when the model is set. - // A root read inside createTreeView finds no handle yet; the next root read writes the message. - // After dispose the handle is gone: nothing is written. - if ( - isTreeViewCreated && - !isDisposed && - model.message !== appliedMessage - ) { - appliedMessage = model.message - treeView.message = model.message - } return model.rows.map((row) => row.id) }, getTreeItem: (rowId) => { @@ -221,18 +208,13 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { }, }) - isTreeViewCreated = true - // A root read inside createTreeView found no handle; the message of the model shown now is applied once, not left to a later change. - if (model.message !== appliedMessage) { - appliedMessage = model.message - treeView.message = model.message - } // The editor reports whether the view is shown now; a collapsed view runs no poll. isViewVisible = treeView.visible /** * Shows a model: sets the context key and fires one tree change, only for what differs from what is shown. - * The message is not written here; the tree writes it when the provider's getChildren next reads the root children. + * + * Task 16 (N56): the editor's extension host merges tree changes and message writes into one emission through a leading 200 ms debounce (`Event.debounce(merge, 200, true)` in `ExtHostTreeView`), sending the message first and the root refresh right after. The first event of a quiet period goes out alone at once, every later one within 200 ms goes out together at the window's end. A message written in `getChildren` (a reaction to the leading tree change) therefore reached the screen about 200 ms after the rows, and one written before the tree change reached it before them (N55-F3). So a changed message is written as: the current message again (a same-value write that goes out at once and opens the window), then the new message, then the tree change, all in this tick; the last two merge into one emission that carries both. */ const show = (next: LlmWikiViewModel): void => { // A read that finished after dispose shows nothing. @@ -249,6 +231,12 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { LLM_WIKI_STATE_CONTEXT_KEY, next.state, ) + if (next.message !== appliedMessage) { + // The leading emission carries only this same-value write, which changes nothing on screen. + treeView.message = appliedMessage + appliedMessage = next.message + treeView.message = next.message + } treeChanges.fire() } From 7ff3ab576baa5a958edaa045ae402652b7211198 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 10:19:54 +0900 Subject: [PATCH 15/31] fix: Cycle 0056 pt3: the LLM Wiki view's message leaves with the reply that carries the new rows at a run's end (task 16) --- extension/src/tests/llmWikiView.test.ts | 12 ++---- .../src/tests/llmWikiViewReadLoop.test.ts | 33 +++++++++++------ .../src/tests/utils/fakeLlmWikiEditor.ts | 37 +++++++++++-------- extension/src/utils/llmWikiView.ts | 26 +++++++++---- 4 files changed, 64 insertions(+), 44 deletions(-) diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index 4dfbfc84..0b6f5c43 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -1249,8 +1249,8 @@ test('a collapsed view runs no freshness check for a manifest event and checks o ) }) -// Value: protects=N55-F3 and task 16 (the message and the rows change in one emission of the extension host's debounce); fails_when=the view writes the new message apart from the tree change (before it with no window open: N55-F3; in getChildren after the leading change: about 200 ms late, N56), or omits the same-value write that opens the debounce window; why_changed=the message is written in the same tick as the change event (old message again, new message, change) instead of when the tree reads the rows; no_claim=the editor's frames and the debounce itself (the read-loop test models it; N56 step 4); seam=the harness's render log of change events and message writes -test('a changed message is written right before the tree change, after a same-value write of the old message', async (context) => { +// Value: protects=N55-F3 and task 16 (the message and the rows change together); fails_when=the view writes the tree message when it sets its model or with the change event (before the editor reads the new rows, N55-F3; the native take of the earlier task 16 fix showed it one frame ahead of the rows because the host sends a message at once and the rows a round trip later); why_changed=same expectation as before task 16 again: the new message is written when the root children are read, after the change event, with only a same-value write of the old message before it; no_claim=the editor's frames and the host's debounce (the read-loop test models it; N56 step 4); seam=the harness's render log of change events, root reads and message writes +test('the message changes only when the tree reads the new rows, not when the change event fires', async (context) => { // Arrange const project = await openEmptyProject(context, { chatReply: () => new Promise(() => {}), @@ -1270,14 +1270,10 @@ test('a changed message is written right before the tree change, after a same-va const log = project.llmWiki.renderLog() const writeIndex = log.indexOf(`message:${stoppedMessage}`) assert.deepEqual( - [project.llmWiki.message(), log.slice(writeIndex - 1, writeIndex + 2)], + [project.llmWiki.message(), log.slice(writeIndex - 2, writeIndex + 1)], [ stoppedMessage, - [ - `message:${watchingMessage}`, - `message:${stoppedMessage}`, - `change:${stoppedMessage}`, - ], + [`change:${watchingMessage}`, 'getChildren', `message:${stoppedMessage}`], ], ) }) diff --git a/extension/src/tests/llmWikiViewReadLoop.test.ts b/extension/src/tests/llmWikiViewReadLoop.test.ts index ce174f8b..6846976c 100644 --- a/extension/src/tests/llmWikiViewReadLoop.test.ts +++ b/extension/src/tests/llmWikiViewReadLoop.test.ts @@ -1150,8 +1150,8 @@ test('showing the view again with a read that still fails restarts the poll acro assert.equal(reads - readsAfterShown, 1) }) -// Value: protects=task 16 (N56: the message lagged the rows by about 200 ms at a run's end); fails_when=the message goes out in a later emission than the rows (written in getChildren after the leading tree change) or before them, so the editor paints rows under the old message or the new message over the old rows; why_new=the extension host sends a leading tree change alone and merges the rest for 200 ms, which the old message write in getChildren fell into; seam=the fake editor's model of ExtHostTreeView's leading 200 ms debounce; no_claim=the native editor's paint (N56 step 4) -test('a run ending changes the message and the rows in the one emission of the extension host’s debounce', async (context) => { +// Value: protects=task 16 (N56 native takes: the message lagged the rows by about 200 ms with the write in getChildren, then led them by one frame when sent with the refresh request); fails_when=the new message is sent before the root read that returns the new rows (in the emission that only queues the refresh, a round trip ahead of the rows), or in a later emission than the rows; why_new=the extension host sends $setMessage at once but the refresh takes $refresh and $getChildren before the rows paint, so only a write inside that root read leaves with the rows; seam=the fake editor's model of ExtHostTreeView (leading 200 ms debounce, refresh queued asynchronously, root read); no_claim=the native editor's paint (N56 step 4) +test('a run ending sends the cleared message during the root read that returns the new rows, and not before it', async (context) => { // Arrange context.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }) let isRunning = true @@ -1161,25 +1161,33 @@ test('a run ending changes the message and the rows in the one emission of the e { modelsExtensionHostDebounce: true }, ) await view.whenIdle() + // The run's start: its trailing emission, the root read that writes the watching message, and the window that write opens. context.mock.timers.tick(200) - const emissionsBeforeTheEnd = fake.emissions().length + await Promise.resolve() + context.mock.timers.tick(200) + const logBeforeTheEnd = fake.hostLog().length isRunning = false // Act await view.refresh() context.mock.timers.tick(200) + await Promise.resolve() // Assert - assert.deepEqual(fake.emissions().slice(emissionsBeforeTheEnd), [ + assert.deepEqual(fake.hostLog().slice(logBeforeTheEnd), [ // The leading emission: the same-value write of the old message, which changes nothing on screen. - { hasMessage: true, message: WATCHING_MESSAGE, refreshesRoot: false }, - // The trailing emission: the cleared message and the root refresh together. - { hasMessage: true, message: undefined, refreshesRoot: true }, + `emit message(${WATCHING_MESSAGE})`, + // The trailing emission carries only the refresh request. + 'emit refresh', + // The cleared message leaves while the main thread reads the root, right before the new rows return. + 'root-read', + 'emit message(none)', + 'root-return()', ]) }) -// Value: protects=SM2-7 (message cleared x tree change) and task 16; fails_when=a state without a message never clears the old one, or clears it in getChildren (after the leading tree change, so the extension host sends it about 200 ms after the rows); why_changed=the message is no longer written when the tree reads its rows: show writes the old message again, then the new one, then fires the tree change; no_claim=the editor's frames; seam=the fake tree's render log -test('a state without a message clears the message in the same tick as the tree change, before the tree reads its rows', async (context) => { +// Value: protects=SM2-7 (message cleared x getChildren) and N55-F3; fails_when=a state without a message writes the cleared message before the tree reads the new rows, or never clears the old one; why_changed=show now writes the current message again (a same-value write that opens the extension host's debounce window) before the tree change, so the log starts with a message write; no_claim=the editor's frames; seam=the fake tree's render log +test('a state without a message clears the message when the tree reads its rows, after getChildren', async (context) => { // Arrange let isRunning = true const { fake, view } = openView(context, { @@ -1196,11 +1204,11 @@ test('a state without a message clears the message in the same tick as the tree // Assert assert.deepEqual(fake.renderLog(), [ 'message:undefined', - `message:${WATCHING_MESSAGE}`, 'getChildren', `message:${WATCHING_MESSAGE}`, - 'message:undefined', + `message:${WATCHING_MESSAGE}`, 'getChildren', + 'message:undefined', ]) }) @@ -1374,13 +1382,14 @@ test('a freshness check that keeps failing is recorded once in Output, and again ]) }) -// Value: protects=F8 (no write on a disposed tree view); fails_when=a root read after dispose writes a message on the disposed tree view; why_changed=the message is written by show, so the guard is that a read after dispose changes nothing; no_claim=the message before dispose; seam=a run row's message shown, then dispose, then a root read +// Value: protects=F8 (no write on a disposed tree view); fails_when=a root read after dispose writes a message on the disposed tree view; why_changed=the message is written by the root read again, so the root read before dispose applies it and the guard is that a read after dispose changes nothing; no_claim=the message before dispose; seam=a run row's message shown, then dispose, then a root read test('a root read after dispose writes no message on the tree view', async (context) => { // Arrange const { fake, view } = openView(context, { readFacts: async () => runningFacts(), }) await view.whenIdle() + fake.rows() view.dispose() const logAtDispose = fake.renderLog() diff --git a/extension/src/tests/utils/fakeLlmWikiEditor.ts b/extension/src/tests/utils/fakeLlmWikiEditor.ts index 69d23df7..cf3bb3e2 100644 --- a/extension/src/tests/utils/fakeLlmWikiEditor.ts +++ b/extension/src/tests/utils/fakeLlmWikiEditor.ts @@ -27,29 +27,34 @@ export type FakeTreeRow = { export function createFakeLlmWikiEditor( fakeOptions: { readsRootInsideCreateTreeView?: boolean - /** Models the extension host's `ExtHostTreeView`: tree changes and message writes feed one leading 200 ms debounce whose emission sends the message, then refreshes the root (`emissions()` records each). Uses `setTimeout`, so a test enables mock timers and ticks 200 ms. */ + /** Models the extension host's `ExtHostTreeView`: tree changes and message writes feed one leading 200 ms debounce. An emission sends the message synchronously (`$setMessage`) and only queues the refresh (`refreshQueue.then`, a microtask here); the refresh then makes the main thread read the root children (`$refresh`, then `$getChildren` back), and a message written during that read is a new emission. `hostLog()` records emissions and root reads in order. Uses `setTimeout`, so a test enables mock timers, ticks 200 ms and awaits a microtask. */ modelsExtensionHostDebounce?: boolean } = {}, ) { const renderLog: string[] = [] - /** What each emission of the modelled extension host's debounce carried. */ - const emissions: Array<{ - hasMessage: boolean - message: string | undefined - refreshesRoot: boolean - }> = [] + /** What the modelled extension host did, in order: `emit message()` and/or `+refresh` for each emission of its debounce, `root-read` and `root-return` around each root read the refresh caused. */ + const hostLog: string[] = [] /** What the modelled debounce has merged since its last emission. */ let pending = { hasMessage: false, refreshesRoot: false } let debounceHandle: NodeJS.Timeout | undefined let debounceCalls = 0 const emit = (carried: typeof pending): void => { - emissions.push({ - hasMessage: carried.hasMessage, - message: carried.hasMessage ? shownMessage : undefined, - refreshesRoot: carried.refreshesRoot, - }) - // The handler sends the message first, then refreshes the root (`$getChildren`). - if (carried.refreshesRoot) provider?.getChildren() + // The handler sends the message first, synchronously, and only queues the refresh. + hostLog.push( + `emit ${[ + carried.hasMessage ? `message(${shownMessage ?? 'none'})` : undefined, + carried.refreshesRoot ? 'refresh' : undefined, + ] + .filter((part) => part !== undefined) + .join(' + ')}`, + ) + if (carried.refreshesRoot) + queueMicrotask(() => { + // The main thread's `$getChildren` back to the extension host. + hostLog.push('root-read') + const ids = provider?.getChildren() ?? [] + hostLog.push(`root-return(${ids.join(',')})`) + }) } /** `Event.debounce(merge, 200, true)`: the first event of a quiet period is emitted at once with its own data, later ones merge into one emission when 200 ms pass without an event. */ const feedDebounce = (event: Partial): void => { @@ -190,8 +195,8 @@ export function createFakeLlmWikiEditor( }), contextKeys, files, - /** The emissions of the modelled extension host's debounce so far ({@link createFakeLlmWikiEditor} option `modelsExtensionHostDebounce`). */ - emissions: () => emissions.map((emission) => ({ ...emission })), + /** What the modelled extension host did so far ({@link createFakeLlmWikiEditor} option `modelsExtensionHostDebounce`). */ + hostLog: () => [...hostLog], /** The message the tree view shows now. */ message: (): string | undefined => shownMessage, /** What the fake tree did, in order: `getChildren` for each root read and `message:` for each message write. */ diff --git a/extension/src/utils/llmWikiView.ts b/extension/src/utils/llmWikiView.ts index 56d114ee..e96e2374 100644 --- a/extension/src/utils/llmWikiView.ts +++ b/extension/src/utils/llmWikiView.ts @@ -154,7 +154,9 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { /** The key ({@link failureKeyOf}) of the last failure recorded in Output, per kind: `read` is cleared when a good model is shown, `freshness` when a freshness check succeeds, so a failure that persists across poll ticks is recorded once and a different one is recorded too. */ const recordedFailureKeys: Record<'read' | 'freshness', string | undefined> = { read: undefined, freshness: undefined } - /** The message the tree view shows now; {@link show} writes a changed one together with the tree change. */ + /** Whether the tree view handle exists; an editor may read the provider while it is being created. */ + let isTreeViewCreated = false + /** The message the tree view shows now; the model's next one is written when the tree reads its root children ({@link show}). */ let appliedMessage: string | undefined let pollTimer: NodeJS.Timeout | undefined let isDisposed = false @@ -177,6 +179,16 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { onDidChangeTreeData: treeChanges.event, getChildren: (element) => { if (element) return [] + // Task 16 (N56): the message leaves the extension host in the same macrotask as the reply carrying the new rows, so both paint together; see {@link show}. + // A root read inside createTreeView finds no handle yet (the first model has no message); after dispose nothing is written. + if ( + isTreeViewCreated && + !isDisposed && + model.message !== appliedMessage + ) { + appliedMessage = model.message + treeView.message = model.message + } return model.rows.map((row) => row.id) }, getTreeItem: (rowId) => { @@ -208,13 +220,15 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { }, }) + isTreeViewCreated = true // The editor reports whether the view is shown now; a collapsed view runs no poll. isViewVisible = treeView.visible /** * Shows a model: sets the context key and fires one tree change, only for what differs from what is shown. * - * Task 16 (N56): the editor's extension host merges tree changes and message writes into one emission through a leading 200 ms debounce (`Event.debounce(merge, 200, true)` in `ExtHostTreeView`), sending the message first and the root refresh right after. The first event of a quiet period goes out alone at once, every later one within 200 ms goes out together at the window's end. A message written in `getChildren` (a reaction to the leading tree change) therefore reached the screen about 200 ms after the rows, and one written before the tree change reached it before them (N55-F3). So a changed message is written as: the current message again (a same-value write that goes out at once and opens the window), then the new message, then the tree change, all in this tick; the last two merge into one emission that carries both. + * Task 16 (N56): the editor's extension host merges tree changes and message writes through a leading 200 ms debounce (`Event.debounce(merge, 200, true)` in `ExtHostTreeView`). Its handler sends `$setMessage` at once but only queues the refresh, which takes a round trip (`$refresh` to the main thread, `$getChildren` back, then the render) before the rows paint. A message written here, with the change, paints a round trip ahead of the rows (native take of the earlier fix: one frame before); one written in `getChildren` during the leading emission's window goes out 200 ms late (first native take). + * So a changed message is not written here: the current message is written again (a same-value write that goes out at once and opens the window), and the tree change merges into the trailing emission, which closes the window. The new message is then written in the root `getChildren` the refresh causes, a new leading emission that leaves with the reply carrying the new rows. */ const show = (next: LlmWikiViewModel): void => { // A read that finished after dispose shows nothing. @@ -231,12 +245,8 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { LLM_WIKI_STATE_CONTEXT_KEY, next.state, ) - if (next.message !== appliedMessage) { - // The leading emission carries only this same-value write, which changes nothing on screen. - treeView.message = appliedMessage - appliedMessage = next.message - treeView.message = next.message - } + // The leading emission carries only this same-value write, which changes nothing on screen; it opens the debounce window so the tree change below merges into the trailing emission. + if (next.message !== appliedMessage) treeView.message = appliedMessage treeChanges.fire() } From 6aac5414fb03f0dc21d087c7847eef2ef765df49 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 11:07:39 +0900 Subject: [PATCH 16/31] test: Cycle 0056 pt3: the live-entry measurement test derives its expected layout reads from the arrangement and asserts the chat's keyboard premise (CR1) --- extension/src/tests/studyWorkspaceEntry.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/extension/src/tests/studyWorkspaceEntry.test.ts b/extension/src/tests/studyWorkspaceEntry.test.ts index 153aebbd..59a697ba 100644 --- a/extension/src/tests/studyWorkspaceEntry.test.ts +++ b/extension/src/tests/studyWorkspaceEntry.test.ts @@ -2784,9 +2784,8 @@ for (const besideChat of [false, true]) harness.holdDisposedPanelTabCloses() // Observe actual native effects without changing their arguments, responses or timing, from before the run starts: the live entry's final arrangement can run while the run prepares, on a loaded machine before the explicit open below (F154). harness.editor.commands.executeCommand = async (...args) => { - if (args[0] === 'vscode.getEditorLayout') + if (args[0] === 'vscode.getEditorLayout') { chatKeyboardAtReads.push(chat?.focused() === true) - if (args[0] === 'vscode.getEditorLayout') readsWithRetiredTab.push( harness.groups.some((group) => group.tabs.some( @@ -2796,12 +2795,14 @@ for (const besideChat of [false, true]) ), ), ) + } return executeCommand.call(harness.editor.commands, ...args) } harness.reader['arrange'] = async (...args): Promise => { const start = harness.commands.length, readsStart = readsWithRetiredTab.length - if (args[5] !== undefined) chat?.focusTab() // PROBE + // The learner types in the chat when the final arrangement begins beside it: the measurement then meets a chat that holds the keyboard, which is the premise of the extra layout read asserted below. + if (args[5] !== undefined) chat?.focusTab() const arranged = arrange.apply(harness.reader, args) // The arrangement that retires the context pane runs up to its wait for the tab's close in this turn; the editor closes the tab in the next one. if (args[5] !== undefined) @@ -2853,8 +2854,9 @@ for (const besideChat of [false, true]) const layoutReadsBeforeBatch = commands .slice(0, batch) .filter((command) => command === 'vscode.getEditorLayout').length - const chatHadKeyboardAtFirstRead = finalChatKeyboardAtReads[0] === true - assert.equal(layoutReadsBeforeBatch, chatHadKeyboardAtFirstRead ? 2 : 1) + // The premise: the chat holds the keyboard at the first read exactly when the arrangement stands beside the chat. + assert.equal(finalChatKeyboardAtReads[0], besideChat) + assert.equal(layoutReadsBeforeBatch, besideChat ? 2 : 1) if (chat) { assert.equal(chat.focused(), true) assert.deepEqual( From 932cdae35ba8dfe2ef2a6e39367fb9f73494a0b1 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 11:17:17 +0900 Subject: [PATCH 17/31] fix: Cycle 0056 pt3: local review fixes (sanitized unreported-focus entry, held-back closed replay, told dropped summary choices, cancelled late-activation wait, Wiki watch README) --- extension/README.md | 4 +- extension/scripts/study-bootstrap-qa.mjs | 18 ++ extension/src/constants.ts | 18 +- extension/src/studyWorkspace.ts | 152 ++++++++++------ .../studyWorkspaceEntryObstacles.test.ts | 78 +++++++++ extension/src/tests/studyWorkspaceHarness.ts | 12 +- ...tudyWorkspaceTestReplayComposition.test.ts | 163 ++++++++++++++++++ 7 files changed, 383 insertions(+), 62 deletions(-) diff --git a/extension/README.md b/extension/README.md index c5a51cf2..8ea7bf51 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice made in the Learning summary while the Study workspace is closed, closing, or no longer showing a replay is not carried out: Codebook says so in one notification ("the Study workspace is closed", "is closing" or "the Learning summary no longer shows a replay", each with what to do next), and **Output › Codebook** records one `choice:dropped` entry with the same reason. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. @@ -256,7 +256,7 @@ The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site - **`Output: `** shows the resolved folder with **Change Location** (the `codebook.wikiOutputDirectory` setting) whenever another row shows. A folder outside the project shows its absolute path. - A read that fails shows `Could not read the LLM Wiki state` with `Refresh to retry`; a click opens Output › Codebook, where the cause is written. -The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. The message above the tree changes in the same render as the rows. When the Wiki folder's parent does not exist yet, as in a fresh project, Codebook watches the closest folder of the project that does (never above the project folder) and moves the watch once a run makes the parent; the watch includes the Wiki folder itself, so deleting or renaming it is seen. An output folder outside the project is watched only when its own parent exists; the home folder and a file-system root are never watched. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. +The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. The message above the tree changes in the same render as the rows. Inside the project the watch stands on the project folder from the start and stays there, also in a fresh project whose Wiki folder's parent does not exist yet, so creating the Wiki folder or deleting or renaming it or a folder above it is seen (never the home folder or a drive root: when the project folder is one of those, the watch stands on the nearest existing folder below it). An output folder outside the project is watched from its own parent only when that exists; the home folder and a file-system root are never watched. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. ### Troubleshooting the LLM Wiki diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 80d39c0b..c2ab70c7 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -8724,6 +8724,24 @@ async function check(page, options, collected) { afterReaderOpened.chosen === shownStudy.id, }) + // Arrange: the learner chose a saved study that cannot be opened, to delete it, and the Study workspace closes (the bottom panel's Saved studies survives the close and offers the delete control). + await library([shownStudy, staleStudy], shownStudy.id) + await page.selectOption('#studies', staleStudy.id) + // Act + await library([shownStudy, staleStudy], shownStudy.id, { + readerClosed: true, + }) + const unopenableWhileReaderClosed = await removalControl() + // Assert + // Value: protects=the learner's own choice of a saved study that cannot be opened, to delete it, surviving Close Study Workspace; fails_when=the closed reader empties the list and the delete control loses the study the learner chose (task 31 clears only the study the reader showed: a study that cannot be opened has no open for a repeated choice to miss); why_new=local CodeRabbit CR2 (Cycle 0056 pt3), decided as not a defect; seam=the Study messages the host posts; no_claim=the host's delete + cases.push({ + name: 'a saved study that cannot be opened and was chosen for deletion stays chosen, with its delete control, after Close Study Workspace', + actual: { unopenableWhileReaderClosed }, + passed: + unopenableWhileReaderClosed.chosen === staleStudy.id && + unopenableWhileReaderClosed.ariaDisabled === null, + }) + // Arrange: a delete request the host refuses, shown under the button (A-L5, AD-14, DR-26). const sameRefusal = { status: 'refused', diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 077329bb..90e86800 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1852,5 +1852,21 @@ export const REFUSAL_REPORT_EVENTS = 24 /** The most native effects Codebook itself issued (reveals, disposals, editor commands, shown documents) that a {@link PlacementEventLog} keeps for a placement refusal report, in effects. */ export const REFUSAL_REPORT_EFFECTS = 48 -/** How long a new Application group's preparation waits for the editor's own report that it activated the group, in ms; Cursor sends it after the command that made the group answered (N55-F4, row 650). It ends at the report, or here when the editor sends none. */ +/** How long a new Application group's preparation waits for the editor's own report that it activated the group, in ms; Cursor sends it after the command that made the group answered (N55-F4, row 650). It ends at the report, or here when the editor sends none. The bound is empirical, not derived: N56 on candidates `5b39b767` and `02acefaa` opened the Study workspace 13 times in Cursor 3.27.12 and the report always arrived within it, with no refusal; a report that arrives later is read by the guards as a change the learner made and refuses the open with a report naming the late activation. */ export const LATE_GROUP_ACTIVATION_WAIT_MS = 150 + +/** Why the reader did not carry out a choice the Learning summary sent while the Study workspace was closed; completes "The … choice from the Learning summary was not carried out: " in Output › Codebook and "Codebook did not carry out your choice: " in the notice the learner sees. */ +export const SUMMARY_CHOICE_DROPPED_REASON_CLOSED = + 'the Study workspace is closed. Open Study Workspace, then choose it again.' + +/** The reason when the choice arrived while the Study workspace was closing. See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ +export const SUMMARY_CHOICE_DROPPED_REASON_CLOSING = + 'the Study workspace is closing. Wait until it has closed, open it with Open Study Workspace, then choose it again.' + +/** The reason when the reader had left the summary composition (the Study pane stands again). See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ +export const SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT = + 'the Learning summary no longer shows a replay. Choose it again from Saved studies in the Study workspace.' + +/** What the learner's notice for a dropped Learning summary choice starts with, before the reason. */ +export const SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX = + 'Codebook did not carry out your choice: ' diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 5fd56e28..b5e806b1 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -81,6 +81,10 @@ import { STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH, STUDY_REMOVAL_WITHOUT_TRASH_MESSAGE, STUDY_SOURCE_SCHEME, + SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX, + SUMMARY_CHOICE_DROPPED_REASON_CLOSED, + SUMMARY_CHOICE_DROPPED_REASON_CLOSING, + SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT, SUMMARY_DISPLACED_OFFER, SUMMARY_FOCUS_RELEASE_WAIT_MS, SUMMARY_PLACEMENT_ACTION, @@ -983,6 +987,10 @@ export class StudyWorkspace { private nodeHistoryEntryTurn: Promise = Promise.resolve() /** `true` while {@link openNodeHistoryFromHost} enters the summary composition for a test replay that has not begun opening ({@link testReplayHoldsComposition}). */ private hostOpeningTestReplay = false + /** + * The replay a closed reader keeps selected for its next Open Study Workspace while an entry for another replay opens; the Learning summary and the bottom panel receive no message that carries it ({@link publish}). It is released when the entry ends ({@link openNodeHistoryFromHost}), and it holds nothing once the chosen replay replaces the selection. + */ + private heldBackReplay: { entry: number; studyId: string } | undefined /** The editor's recent group and active text editor changes, for {@link refusePlacement}'s report (F382). */ private readonly placementEvents = new PlacementEventLog() /** The native effects this host itself issued (reveals, disposals, editor commands, shown documents), for {@link refusePlacement}'s report (N55-F4). */ @@ -11872,51 +11880,59 @@ export class StudyWorkspace { ): Promise { const entry = this.supersedeNodeHistoryEntries(), overtaken = this.nodeHistoryEntryCancellation.signal - // Entries take turns entering the composition; a newer entry made meanwhile is the learner's latest choice (EN19). - const previousTurn = this.nodeHistoryEntryTurn - let releaseTurn!: () => void - this.nodeHistoryEntryTurn = new Promise((resolve) => { - releaseTurn = resolve - }) - this.nodeHistoryEntriesUnderWay++ - let entered = false try { - await previousTurn - entered = await this.enterCompositionForEntry(studyId, entry, overtaken) - } finally { - this.nodeHistoryEntriesUnderWay-- - releaseTurn() - } - if (!entered) { - // The last superseded entry to end leaves the summary composition an earlier one built, when no entry is under way to fill it: a Return or a Close superseded them all (Cycle 0052 task 12). + // Entries take turns entering the composition; a newer entry made meanwhile is the learner's latest choice (EN19). + const previousTurn = this.nodeHistoryEntryTurn + let releaseTurn!: () => void + this.nodeHistoryEntryTurn = new Promise((resolve) => { + releaseTurn = resolve + }) + this.nodeHistoryEntriesUnderWay++ + let entered = false + try { + await previousTurn + entered = await this.enterCompositionForEntry(studyId, entry, overtaken) + } finally { + this.nodeHistoryEntriesUnderWay-- + releaseTurn() + } + if (!entered) { + // The last superseded entry to end leaves the summary composition an earlier one built, when no entry is under way to fill it: a Return or a Close superseded them all (Cycle 0052 task 12). + if ( + entry !== this.nodeHistoryEntryRequest && + this.nodeHistoryEntriesUnderWay === 0 + ) + await this.leaveEmptySummaryComposition() + return + } + if (entry !== this.nodeHistoryEntryRequest) return + const outcome = await this.openNodeHistory( + studyId, + revision ?? this.revision, + ) + if (outcome.status === 'superseded') { + // A superseded open leaves the summary composition it entered when no newer replay stands in it, as a failed open does (Cycle 0052 task 13); an entry still under way fills or leaves that composition itself (task 35). + if (this.nodeHistoryEntriesUnderWay === 0) + await this.leaveEmptySummaryComposition() + return + } + if (outcome.status !== 'failed') { + // The learner's own replay open, after the replay is shown: the summary moves beside the chat once per profile (F209). + await this.moveSummaryBesideChatOnce() + return + } if ( - entry !== this.nodeHistoryEntryRequest && - this.nodeHistoryEntriesUnderWay === 0 + (await this.answerFailedTestReplayOpen(studyId, outcome.error)) === + 'retry' ) - await this.leaveEmptySummaryComposition() - return - } - if (entry !== this.nodeHistoryEntryRequest) return - const outcome = await this.openNodeHistory( - studyId, - revision ?? this.revision, - ) - if (outcome.status === 'superseded') { - // A superseded open leaves the summary composition it entered when no newer replay stands in it, as a failed open does (Cycle 0052 task 13); an entry still under way fills or leaves that composition itself (task 35). - if (this.nodeHistoryEntriesUnderWay === 0) - await this.leaveEmptySummaryComposition() - return - } - if (outcome.status !== 'failed') { - // The learner's own replay open, after the replay is shown: the summary moves beside the chat once per profile (F209). - await this.moveSummaryBesideChatOnce() - return + return this.openNodeHistoryFromHost(studyId) + } finally { + // The closed reader's replay held back for this entry is drawn again, by the next Open Study Workspace if this entry did not replace it (Cycle 0056 task 32). + if (this.heldBackReplay?.entry === entry) { + this.heldBackReplay = undefined + this.lastPublished = undefined + } } - if ( - (await this.answerFailedTestReplayOpen(studyId, outcome.error)) === - 'retry' - ) - return this.openNodeHistoryFromHost(studyId) } /** @@ -12034,7 +12050,7 @@ export class StudyWorkspace { /** * Opens the summary composition for a test replay that is about to open (Cycle 0051, DS-A): a closed reader opens, and the Live composition and the Study composition give way to it, so the Study pane closes and the Learning summary takes the chat's place; called by {@link openNodeHistoryFromHost}. * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). - * A closed reader keeps the replay it showed for the next Open Study Workspace ({@link close}); an entry for another replay retires that selection before {@link open} publishes, so the Learning summary never draws the closed reader's replay (its title, result and stop) while the chosen one opens (Cycle 0056 task 32). An entry for the same replay keeps it and shows it at once. + * A closed reader keeps the replay it showed for the next Open Study Workspace ({@link close}); an entry for another replay holds that selection back ({@link heldBackReplay}) before {@link open} publishes, so the Learning summary never draws the closed reader's replay (its title, result and stop) while the chosen one opens, and a refused entry leaves it selected for the next Open Study Workspace (Cycle 0056 task 32). An entry for the same replay keeps it and shows it at once. * @throws What {@link open} throws; the caller tells it. * @param studyId - The test replay's study id. * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}); an entry a newer one or a Return overtook while it waited for the work window reveals nothing. @@ -12062,15 +12078,16 @@ export class StudyWorkspace { } this.hostOpeningTestReplay = true try { - // A closed reader still holds the replay it showed: another replay's entry drops it before open() publishes the summary, and the retirement's native work is rechecked against the entry. + // A closed reader still holds the replay it showed for the next Open Study Workspace: while another replay's entry opens, no view draws it, and a refused entry leaves it selected (Cycle 0056 task 32). if ( !this.opened && this.selectedNodeHistory && this.selectedNodeHistory.header.studyId !== studyId - ) { - await this.retireNodeHistorySelection() - overtaken.throwIfAborted() - } + ) + this.heldBackReplay = { + entry, + studyId: this.selectedNodeHistory.header.studyId, + } // The open's waits for the work window end too when a newer entry, a Return or a Close supersedes this one (Cycle 0052 task 44). await this.open(overtaken) } finally { @@ -13933,13 +13950,17 @@ export class StudyWorkspace { this.lastPublished = signature void this.contextPanel?.webview.postMessage(compact) // The Learning summary draws only a shown replay: a publish while the reader leaves one would draw the Live Study page there (Cycle 0052 task 2, N51-V2). - if (this.summaryComposition && compact.replayFamily) + // A replay held back for another replay's entry is drawn by no view (Cycle 0056 task 32). + const isHeldBack = + this.heldBackReplay !== undefined && + this.selectedNodeHistory?.header.studyId === this.heldBackReplay.studyId + if (this.summaryComposition && compact.replayFamily && !isHeldBack) void this.summaryView?.webview.postMessage(compact) if (this.moments) this.moments.title = this.summaryComposition ? 'Learning replay' : 'Observed moments' - void this.moments?.webview.postMessage(compact) + if (!isHeldBack) void this.moments?.webview.postMessage(compact) if (this.outline) this.outline.message = message.model.currentSourceAvailable ? `${message.model.understoodCount} of ${message.model.progressByItem.length} understood for current source · ${message.model.reviewCount} needs review` @@ -22211,6 +22232,8 @@ export class StudyWorkspace { identity: symbol, signal: AbortSignal | undefined, ): Promise { + // A preparation cancelled before the wait begins waits for nothing. + if (signal?.aborted) return await new Promise((resolve) => { const subscription = vscode.window.tabGroups.onDidChangeTabGroups( (event) => { @@ -22553,6 +22576,7 @@ export class StudyWorkspace { /** * Writes one Output › Codebook entry for a main editor that reported no keyboard focus, and returns the refusal sentence the entry throws; called by {@link readMainEditorGrid} when its wait ends (F389). + * The entry's tab labels and events are sanitized as {@link refusePlacement}'s are, so no home, storage or repository path reaches Output. * The entry names the groups and tabs before the first group was focused and now, so the next native take tells what the first group held while the editor reported nothing. * @param armed - The time the wait began and the groups before the focus command. * @returns The sentence {@link unreportedEditorFocusObstacle} words. @@ -22570,7 +22594,9 @@ export class StudyWorkspace { nowMs: performance.now(), before: this.sanitizeRefusalGroups(armed.before), after: this.sanitizeRefusalGroups(this.refusalGroupViews()), - events: this.placementEvents.since(armed.atMs), + events: this.sanitizeRefusalNotes( + this.placementEvents.since(armed.atMs), + ), }), }) return unreportedEditorFocusObstacle( @@ -22696,7 +22722,7 @@ export class StudyWorkspace { } /** - * Runs an editor command and notes it as one of Codebook's own effects; every command this class sends goes through here, instead of `this.executeEditorCommand`, except the reads of the layout and context keys, which change nothing. + * Runs an editor command and notes it as one of Codebook's own effects; every command this class sends goes through here, instead of `vscode.commands.executeCommand`, except the reads of the layout and context keys, which change nothing. * @param command - The command id. * @param rest - Its arguments. * @returns What the editor's command returns. @@ -22725,9 +22751,11 @@ export class StudyWorkspace { this.noteNativeEffect( `showTextDocument column ${options?.viewColumn ?? 'active'} preserveFocus ${options?.preserveFocus ?? false}`, ) - return 'fsPath' in target - ? vscode.window.showTextDocument(target, options) - : vscode.window.showTextDocument(target, options) + // The editor's API takes a document or its uri in one call; its typings declare them as two overloads, which a union does not match. + return vscode.window.showTextDocument( + target as vscode.TextDocument, + options, + ) } /** @@ -23506,7 +23534,7 @@ export class StudyWorkspace { } /** - * Writes one Output › Codebook entry for a learner's choice (a Saved studies entry, a tab or an item) that the Learning summary sent while the Study workspace was closed, so a dropped choice leaves a trace instead of nothing (N55-F4, Cycle 0056 task 17); called by {@link receive} when {@link isMessageOwnerRetired} drops a message. + * Writes one Output › Codebook entry, and shows the learner one non-modal information notice with the same reason, for a learner's choice (a Saved studies entry, a tab or an item) that the Learning summary sent while the Study workspace was closed, closing or had left the summary composition, so a dropped choice leaves a trace and a notice instead of nothing (N55-F4, Cycle 0056 tasks 17 and the local review); called by {@link receive} when {@link isMessageOwnerRetired} drops a message. * The pane that sent the choice is released by {@link receivePaneMessage}'s reply, which is posted whether or not the message was carried out. A renderer a handoff retired is no learner's choice and writes nothing. * @param value - The raw message that was dropped. * @param owner - The view that sent it. @@ -23523,13 +23551,25 @@ export class StudyWorkspace { !CONTROL_RETIRING_MESSAGE_TYPES.has(parsed.data.type) ) return + // The reason the gate dropped it: closing wins, then closed, else the composition was left. + const reason = this.closing + ? SUMMARY_CHOICE_DROPPED_REASON_CLOSING + : this.opened + ? SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT + : SUMMARY_CHOICE_DROPPED_REASON_CLOSED this.diagnostics?.log({ surface: 'choice:dropped', ...('studyId' in parsed.data && typeof parsed.data.studyId === 'string' ? { studyId: parsed.data.studyId } : {}), - cause: `The "${parsed.data.type}" choice from the Learning summary was not carried out: the Study workspace is closed. Open Study Workspace, then choose it again.`, + cause: `The "${parsed.data.type}" choice from the Learning summary was not carried out: ${reason}`, }) + // The learner sees the drop too: a pane that only released its list would otherwise show nothing happened. + void showPlainNotice( + vscode.window, + 'information', + `${SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX}${reason}`, + ) } /** Accepts only bounded named UI messages, rejecting stale controls before dispatch. @example await this.receive(message) */ diff --git a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts index 7e65c03a..3c252f9d 100644 --- a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts +++ b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' import { writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -8626,6 +8627,83 @@ test('Open Study Workspace refused for an unreported main editor focus writes on } }) +// Value: protects=the Output › Codebook entry of an unreported main editor focus never carrying the home directory or a control character that a tab label or an event note held; fails_when=the entry lists the editor's events unsanitized, as refusePlacement's sanitized ones are not (local CodeRabbit, Cycle 0056 pt3 CR3); why_new=the F389 entry's test read the group and tab lines, with no private path in any note; seam=the reader's placementEvents note recorded with a home path and a bell character, the entry's cause; no_claim=the native editor's own labels +test('the Output entry of an unreported main editor focus hides the home directory and control characters in the editor events', async (context) => { + // Arrange + const harness = await recordedReaderHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.useNativeLayout({ orientation: 1, groups: [{}, {}] }) + harness.groups.at(-1)!.tabs = [] + harness.groups.at(-1)!.activeTab = undefined + const chat = harness.addSeparateEditorPart() + harness.replaceGroupObjectsOnTabChanges() + harness.leavePaneFocusUnreportedBesideChat() + const home = homedir() + try { + harness.focusOpaqueTab(harness.addOpaqueTab('Cursor Settings', 1)) + chat.focusTab() + const commands = harness.editor.commands as { + executeCommand: (name: string, ...rest: unknown[]) => Thenable + } + const runCommand = commands.executeCommand.bind(commands) + commands.executeCommand = async (name, ...rest) => { + if (name === 'workbench.action.focusFirstEditorGroup') + harness.reader['placementEvents'].record( + performance.now(), + `tab opened ${home}/private/notes.ts\u0007`, + ) + await advanceClock(context, 100) + return runCommand(name, ...rest) + } + + // Act + const opening = harness.reader.open().then( + () => 'opened', + (error: unknown) => String(error), + ) + enableFakeClock(context) + await advanceClockUntilSettled(context, opening, 6_000) + await opening + resetFakeClock(context) + + // Assert + const entries = logged.filter( + (entry) => entry.surface === 'presentation:focus-unreported', + ) + assert.equal(entries.length, 1) + assert.match(entries[0]!.cause, /tab opened ~\/private\/notes\.ts/) + assert.equal(entries[0]!.cause.includes(home), false) + assert.equal(entries[0]!.cause.includes('\u0007'), false) + } finally { + resetFakeClock(context) + await harness.close() + } +}) + +// Value: protects=a cancelled Application group preparation ending its wait for the editor's late activation report at once; fails_when=the wait runs its full bound when its signal is already aborted, so a Close or a newer entry holds the next arrangement for 150 ms (local open-code-review OCR-L2, Cycle 0056 pt3); why_new=the wait registered its abort listener after the signal had aborted, which never fires; seam=the reader's waitForLateGroupActivation with an aborted signal; no_claim=the native timing of the editor's report +test('the wait for an Application group’s late activation ends at once when its preparation is already cancelled', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const startedAtMs = performance.now() + + // Act + await harness.reader['waitForLateGroupActivation']( + Symbol('group'), + AbortSignal.abort(), + ) + + // Assert + assert.equal(performance.now() - startedAtMs < 50, true) + } finally { + await harness.close() + } +}) + for (const entry of [ "an agent's Study workspace request", 'Open Study Workspace', diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 23852022..e04d5518 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1097,7 +1097,8 @@ export async function workspaceHarness( /** Whether the fake Cursor reports the Application's group active a moment after a reveal of the Application that keeps the keyboard answered; see `activateApplicationGroupLateAfterReveal`. */ let activatesApplicationGroupLateAfterReveal = false /** Whether the fake Cursor reports the group `workbench.action.newGroupAbove` made active only after the command answered; see `activateNewGroupLateAfterInsertion`. */ - let activatesNewGroupLateAfterInsertion = false + let activatesNewGroupLateAfterInsertion = false, + newGroupActivationDelayMs: number | undefined /** The late group activations the fake editor has not reported yet; the first layout read after them reports them while it is in flight, else a timer does. */ const pendingLateGroupActivations: { report: () => void @@ -1272,6 +1273,7 @@ export async function workspaceHarness( function queueLateGroupActivation( group: () => (typeof groups)[number] | undefined, atRead: () => boolean = () => true, + delayMs: number = NEXT_TAB_ACTIVATION_DELAY_MS, ): void { const pending = { report: (): void => {}, atRead } const report = (): void => { @@ -1285,7 +1287,7 @@ export async function workspaceHarness( } pending.report = report pendingLateGroupActivations.push(pending) - runEditorChangeLater(report, NEXT_TAB_ACTIVATION_DELAY_MS) + runEditorChangeLater(report, delayMs) } const automaticallyClosedColumns: number[] = [] let transientLayout = false, @@ -4148,6 +4150,7 @@ export async function workspaceHarness( queueLateGroupActivation( () => inserted, () => activeTabGroup !== inserted, + newGroupActivationDelayMs, ) } return @@ -6026,10 +6029,13 @@ export async function workspaceHarness( }, /** * Makes the fake editor report the group `workbench.action.newGroupAbove` made active only after the command answered, as Cursor activates a new group late (N55-F4 hypothesis H2-b, row 650). + * @param delayMs - How long after the command the report comes; omitted for the editor's usual short delay. * @example harness.activateNewGroupLateAfterInsertion() + * @example harness.activateNewGroupLateAfterInsertion(600) // later than the preparation's wait */ - activateNewGroupLateAfterInsertion(): void { + activateNewGroupLateAfterInsertion(delayMs?: number): void { activatesNewGroupLateAfterInsertion = true + newGroupActivationDelayMs = delayMs }, /** * Makes a disposed panel's `viewType` and `viewColumn` throw `Webview is disposed`, as the editor's WebviewPanel does (`dispose()` itself stays idempotent). diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 712e3663..9d4b48de 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -4276,3 +4276,166 @@ test('a test replay opened after Close of a browser recording never draws the re await harness.close() } }) + +// Value: protects=the replay a closed reader showed surviving an entry for another replay that is refused, so Open Study Workspace still shows it; fails_when=the entry drops the closed reader's selection before its open and the refusal leaves the learner with no replay to restore (local open-code-review, Cycle 0056 pt3 OCR-L3); why_new=task 32 kept the closed reader's replay out of the summary but its first fix retired the selection for good; seam=the reader's open refused once for the second replay's entry, the diagnostics log; no_claim=the notification's wording +test('a refused Open replay of another test replay after Close leaves the closed reader’s replay for Open Study Workspace', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const chosenReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const open = harness.reader.open.bind(harness.reader) + let isFirstOpen = true + harness.reader.open = async (...openArguments): Promise => { + if (isFirstOpen) { + isFirstOpen = false + throw new Error('The editor refused the arrangement.') + } + return open(...openArguments) + } + const drawn = recordDrawnTestReplays(harness.summary) + + // Act: the second replay's entry is refused, then the learner opens the workspace again. + await harness.reader.openLearningReplay(chosenReplay.studyId) + const drawnWhileRefused = [...drawn] + await harness.reader.open() + + // Assert + assert.deepEqual( + { + replay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + drawnWhileRefused: drawnWhileRefused.includes(closedReplay.studyId), + }, + { replay: closedReplay.studyId, drawnWhileRefused: false }, + ) + } finally { + await harness.close() + } +}) + +for (const dropped of [ + { + reason: 'the Study workspace is closed', + prepare: async (harness: Harness): Promise => { + await harness.reader.close() + }, + sentence: + 'the Study workspace is closed. Open Study Workspace, then choose it again.', + }, + { + reason: 'the Study workspace is closing', + prepare: async (harness: Harness): Promise => { + harness.reader['closing'] = true + }, + sentence: + 'the Study workspace is closing. Wait until it has closed, open it with Open Study Workspace, then choose it again.', + }, + { + reason: 'the Learning summary no longer shows a replay', + prepare: async (harness: Harness): Promise => { + await harness.reader.action('return-current') + }, + sentence: + 'the Learning summary no longer shows a replay. Choose it again from Saved studies in the Study workspace.', + }, +] as const) { + // Value: protects=a Saved studies choice the Learning summary sends that the reader does not carry out telling the learner why in the words of that reason, in one notification and in one Output › Codebook entry; fails_when=the drop is recorded only in Output, or says the Study workspace is closed when it is closing or the summary left its replay (local open-code-review OCR-L4, Cycle 0056 pt3); why_new=task 17 recorded the drop but worded every reason as a closed workspace and showed the learner nothing; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor + test(`a Saved studies choice from the Learning summary that is dropped because ${dropped.reason} is told in one notification and one Output entry`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const history = await openByOpenReplay(harness) + const revision = lastView(harness)!.revision + // Other notices (a Close tells where the keyboard went) are not the drop's. + const droppedNotices = (): string[] => + harness.informationMessages + .map((notice) => notice.message) + .filter((message) => + message.startsWith('Codebook did not carry out your choice'), + ) + await dropped.prepare(harness) + + // Act + harness.receive({ + type: 'node-history', + revision, + studyId: history.studyId, + openRequest: 7, + }) + await waitUntil( + () => droppedNotices().length === 1, + 'the learner is told the choice was not carried out', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + notices: droppedNotices(), + entries: logged.map((entry) => entry.cause), + }, + { + notices: [ + `Codebook did not carry out your choice: ${dropped.sentence}`, + ], + entries: [ + `The "node-history" choice from the Learning summary was not carried out: ${dropped.sentence}`, + ], + }, + ) + } finally { + harness.reader['closing'] = false + await harness.close() + } + }) +} + +// Value: protects=an Application group the editor reports active later than the preparation's 150 ms wait: the open is refused with a report of the late activation, never a half-arranged reader; fails_when=the open succeeds with no report or ends in another failure, or the report no longer lists the group change the late activation caused (local open-code-review OCR-L2, Cycle 0056 pt3); why_new=task 17's test pinned only a report that arrives within the bound; seam=workspaceHarness with activateNewGroupLateAfterInsertion(600), the diagnostics log; no_claim=that Cursor ever reports later than the bound (N56 on 5b39b767 and 02acefaa: 13 opens, no refusal) +test('a test replay whose Application group the editor activates after the preparation’s wait is refused with a placement report', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.activateNewGroupLateAfterInsertion(600) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 || + logged.some((entry) => entry.surface === 'open:test-replay'), + 'the choice lands on the first stop or is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + logged.map((entry) => entry.surface), + ['presentation:placement-refused', 'open:test-replay'], + ) + } finally { + await harness.close() + } +}) From 6941035161b3f96747f6ce9634f3d5e293c1eaba Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 11:28:53 +0900 Subject: [PATCH 18/31] test: Cycle 0056 pt3: the cancelled late-activation wait test asserts the wait settles before the next task instead of an elapsed-time bound --- .../src/tests/studyWorkspaceEntryObstacles.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts index 3c252f9d..36b71008 100644 --- a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts +++ b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts @@ -8689,16 +8689,19 @@ test('the wait for an Application group’s late activation ends at once when it // Arrange const harness = await workspaceHarness() try { - const startedAtMs = performance.now() - // Act - await harness.reader['waitForLateGroupActivation']( + const wait = harness.reader['waitForLateGroupActivation']( Symbol('group'), AbortSignal.abort(), + ).then(() => 'ended' as const) + // The next macrotask: a wait that ended at once has settled before it; one that waits for a timer or a report has not. + const nextTask = new Promise<'still waiting'>((resolve) => + setImmediate(() => resolve('still waiting')), ) // Assert - assert.equal(performance.now() - startedAtMs < 50, true) + assert.equal(await Promise.race([wait, nextTask]), 'ended') + await wait } finally { await harness.close() } From 0d2c3e362060ec3d0b8744ee15a9c3b5d03df5d7 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 11:37:25 +0900 Subject: [PATCH 19/31] test: Cycle 0056 pt3: coverage for the home-folder project watch, the quiet drop of a non-choice summary message and a disposed pane's view-state report --- .../src/tests/chooseWikiWatchFolder.test.ts | 17 +++++++ .../studyWorkspaceEntryPlacement.test.ts | 45 +++++++++++++++++ ...tudyWorkspaceTestReplayComposition.test.ts | 50 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/extension/src/tests/chooseWikiWatchFolder.test.ts b/extension/src/tests/chooseWikiWatchFolder.test.ts index fd95a5d3..d490968b 100644 --- a/extension/src/tests/chooseWikiWatchFolder.test.ts +++ b/extension/src/tests/chooseWikiWatchFolder.test.ts @@ -105,3 +105,20 @@ test('the home folder and a file-system root are never watched', () => { // Assert assert.deepEqual(folders, [undefined, undefined, undefined]) }) + +// Value: protects=a project opened at the home folder whose .codebook exists: the watcher stands on .codebook, never on the home folder the project root names; fails_when=the project-root preference ignores the home-folder rule and watches the home folder recursively; why_new=Cycle 0056 task 33 added the project-root preference, and the home-folder test only covers a nearest folder that is the home folder itself; seam=the pure function; no_claim=the editor's watcher +test('a project at the home folder is watched from the existing .codebook below it, not from the home folder', () => { + // Arrange + const choice = { + projectRoot: HOME, + outputDirectory: `${HOME}/.codebook/wiki`, + nearestExistingFolder: `${HOME}/.codebook`, + homeDirectory: HOME, + } + + // Act + const folder = chooseWikiWatchFolder(choice) + + // Assert + assert.equal(folder, '/Users/ada/.codebook') +}) diff --git a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts index 70dc8e54..2c47292d 100644 --- a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts +++ b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts @@ -263,6 +263,51 @@ test('a refused guard lists the tab operation and the pane view-state report the } }) +// Value: protects=an editor view-state report of an Application pane the editor already disposed neither throws out of the reader's listener nor is lost from a refusal's report; fails_when=the view-state note reads the disposed pane's column unguarded, so `Webview is disposed` escapes the listener and a refusal lists no such report; why_new=Cycle 0056 task 17 stage 1 added the note to the view-state listeners, and the disposed-pane tests cover only the close's effect notes; seam=workspaceHarness whose disposed panels throw from viewType and viewColumn, the guard armed through its seam, the diagnostics log; no_claim=the order Cursor sends the report in +test('a view-state report of a disposed Application pane ends quietly and is listed in a refusal as a disposed pane', async () => { + // Arrange + const harness = await workspaceHarness() + harness.throwFromDisposedPanelGetters() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const learnerDraft = harness.focusMutableDraft('learner.ts', 2) + await harness.reader.open() + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + assert.ok(application) + const assertPlacement = harness.reader['retainPresentationPlacement']( + 'exampleCaller/assertDisposed', + ) + + // Act + application.dispose() + application.notifyViewState() + harness.closeTabByHand(learnerDraft.tab) + try { + assertPlacement() + } catch { + // The refusal is written to the log; the test reads it there. + } + + // Assert + const entries = logged.filter( + (entry) => entry.surface === 'presentation:placement-refused', + ) + assert.equal(entries.length, 1) + assert.match( + entries[0]!.cause, + / {2}\+\d+ ms Application view state of a disposed pane\n/, + ) + } finally { + await harness.close() + } +}) + // Value: protects=Codebook: Close Study Workspace after a replay opened (N56 native finding on 8b76a578): panes the editor already disposed with their tabs are disposed again without reading their identity, so the close ends without `Webview is disposed`; fails_when=the effect note reads viewType or viewColumn of a disposed panel, which throws as the editor's WebviewPanel does, and the close rejects; why_new=Cycle 0056 task 17 stage 1 (the new disposal note); seam=workspaceHarness whose disposed panels throw from viewType and viewColumn, an open reader with both panes; no_claim=the editor's own dialog test('closing the Study workspace ends without an error when the editor already disposed its panes', async () => { // Arrange diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 9d4b48de..3dc99b29 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -4399,6 +4399,56 @@ for (const dropped of [ }) } +// Value: protects=a Learning summary message that is no choice (the view's ready report) arriving after Close Study Workspace is dropped without a notification or an Output › Codebook entry; fails_when=the drop notice is raised for every dropped message instead of the learner's choices (history, node-history, tab, select), so each stale report tells the learner a choice was not carried out; why_new=the task 17 tests send only a node-history choice, so a notice for any dropped message would pass them; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor +test('a Learning summary report that is no choice is dropped after Close Study Workspace without telling the learner', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const history = await openByOpenReplay(harness) + const revision = lastView(harness)!.revision + await harness.reader.close() + const noticesBefore = harness.informationMessages.length + + // Act + harness.receive({ type: 'ready' }) + // A later choice is told, so the wait below ends only after the ready report was handled. + harness.receive({ + type: 'node-history', + revision, + studyId: history.studyId, + openRequest: 9, + }) + await waitUntil( + () => harness.openRequestReplies().length === 1, + 'the Learning summary gets the reply to its dropped choice', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + newNotices: harness.informationMessages + .slice(noticesBefore) + .map((notice) => notice.message), + entries: logged.map((entry) => entry.surface), + }, + { + newNotices: [ + 'Codebook did not carry out your choice: the Study workspace is closed. Open Study Workspace, then choose it again.', + ], + entries: ['choice:dropped'], + }, + ) + } finally { + await harness.close() + } +}) + // Value: protects=an Application group the editor reports active later than the preparation's 150 ms wait: the open is refused with a report of the late activation, never a half-arranged reader; fails_when=the open succeeds with no report or ends in another failure, or the report no longer lists the group change the late activation caused (local open-code-review OCR-L2, Cycle 0056 pt3); why_new=task 17's test pinned only a report that arrives within the bound; seam=workspaceHarness with activateNewGroupLateAfterInsertion(600), the diagnostics log; no_claim=that Cursor ever reports later than the bound (N56 on 5b39b767 and 02acefaa: 13 opens, no refusal) test('a test replay whose Application group the editor activates after the preparation’s wait is refused with a placement report', async () => { // Arrange From 784f03a4ca60a923f6fdce536939db7876707c9a Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 12:34:08 +0900 Subject: [PATCH 20/31] fix: Cycle 0056 pt3: pre-landing review fixes (held-back replay redraw, one drop notice per reason, full command names, Manage Workspace Trust failures, state-cell tests, README) --- extension/README.md | 4 +- extension/src/constants.ts | 10 +- extension/src/studyWorkspace.ts | 11 +- extension/src/tests/llmWikiView.test.ts | 35 ++ .../studyWorkspaceEntryObstacles.test.ts | 117 ++++- ...tudyWorkspaceTestReplayComposition.test.ts | 415 +++++++++++++++++- extension/src/utils/chooseWikiWatchFolder.ts | 2 + extension/src/utils/placementRefusalReport.ts | 5 +- .../src/utils/registerLlmWikiCommands.ts | 14 +- 9 files changed, 594 insertions(+), 19 deletions(-) diff --git a/extension/README.md b/extension/README.md index 8ea7bf51..0207f2a6 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load, and the entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry; a Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one; a Saved studies choice made in the Learning summary after **Codebook: Close Study Workspace** is not carried out, and **Output › Codebook** records one `choice:dropped` entry naming the study; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice made in the Learning summary while the Study workspace is closed, closing, or no longer showing a replay is not carried out: Codebook says so in one notification ("the Study workspace is closed", "is closing" or "the Learning summary no longer shows a replay", each with what to do next), and **Output › Codebook** records one `choice:dropped` entry with the same reason. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address, also in an editor that rebuilds its tab objects. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice made in the Learning summary while the Study workspace is closed, closing, or no longer showing a replay is not carried out. Codebook tells you why in one notification that starts `Codebook did not carry out your choice:` and ends with the reason: `the Study workspace is closed` (run **Codebook: Open Study Workspace**, then choose again), `the Study workspace is closing` (run it when it has closed), or `the Learning summary no longer shows a replay` (open a replay from Saved studies, then choose again). You are told once per reason while the workspace stays in that state; opening or closing the workspace lets the next dropped choice tell again. **Output › Codebook** records one `choice:dropped` entry with the reason for every dropped choice, naming the study. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. @@ -256,7 +256,7 @@ The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site - **`Output: `** shows the resolved folder with **Change Location** (the `codebook.wikiOutputDirectory` setting) whenever another row shows. A folder outside the project shows its absolute path. - A read that fails shows `Could not read the LLM Wiki state` with `Refresh to retry`; a click opens Output › Codebook, where the cause is written. -The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. The message above the tree changes in the same render as the rows. Inside the project the watch stands on the project folder from the start and stays there, also in a fresh project whose Wiki folder's parent does not exist yet, so creating the Wiki folder or deleting or renaming it or a folder above it is seen (never the home folder or a drive root: when the project folder is one of those, the watch stands on the nearest existing folder below it). An output folder outside the project is watched from its own parent only when that exists; the home folder and a file-system root are never watched. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. +The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. The message above the tree changes in the same render as the rows. Inside the project the watch stands on the project folder from the start and stays there, also in a fresh project whose Wiki folder's parent does not exist yet, so creating the Wiki folder or deleting or renaming it or a folder above it is seen (never the home folder or a drive root: when the project folder is one of those, the watch stands on the nearest existing folder below it). An output folder outside the project is watched from its own parent only when that exists; the home folder and a file-system root are never watched. The editor reports nothing for the watched folder itself, so deleting or renaming the project folder (or a folder above it) shows after **Refresh** or the next time the view is shown; the same holds for the nearest existing folder that is watched when the project folder is your home folder or a root. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. ### Troubleshooting the LLM Wiki diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 90e86800..a58ee1bb 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1070,6 +1070,10 @@ export const LLM_WIKI_ROW_ACTIONS = { export const LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE = 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.' +/** What the LLM Wiki view says when the editor has the Manage Workspace Trust command ({@link MANAGE_WORKSPACE_TRUST_COMMAND}) but it failed to open; the cause is in Output › Codebook. */ +export const LLM_WIKI_MANAGE_TRUST_FAILED_NOTICE = + 'Manage Workspace Trust could not open. The cause is in Output › Codebook.' + /** What the LLM Wiki view says once the recovery request is copied. */ export const LLM_WIKI_RECOVERY_REQUEST_COPIED_NOTICE = 'Copied the recovery request. Paste it into the editor chat.' @@ -1857,15 +1861,15 @@ export const LATE_GROUP_ACTIVATION_WAIT_MS = 150 /** Why the reader did not carry out a choice the Learning summary sent while the Study workspace was closed; completes "The … choice from the Learning summary was not carried out: " in Output › Codebook and "Codebook did not carry out your choice: " in the notice the learner sees. */ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSED = - 'the Study workspace is closed. Open Study Workspace, then choose it again.' + 'the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.' /** The reason when the choice arrived while the Study workspace was closing. See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSING = - 'the Study workspace is closing. Wait until it has closed, open it with Open Study Workspace, then choose it again.' + 'the Study workspace is closing. When it has closed, run Codebook: Open Study Workspace, then choose it again.' /** The reason when the reader had left the summary composition (the Study pane stands again). See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ export const SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT = - 'the Learning summary no longer shows a replay. Choose it again from Saved studies in the Study workspace.' + 'the Learning summary no longer shows a replay. Open a replay from Saved studies, then choose again.' /** What the learner's notice for a dropped Learning summary choice starts with, before the reason. */ export const SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX = diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index b5e806b1..502a0c0a 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -991,6 +991,8 @@ export class StudyWorkspace { * The replay a closed reader keeps selected for its next Open Study Workspace while an entry for another replay opens; the Learning summary and the bottom panel receive no message that carries it ({@link publish}). It is released when the entry ends ({@link openNodeHistoryFromHost}), and it holds nothing once the chosen replay replaces the selection. */ private heldBackReplay: { entry: number; studyId: string } | undefined + /** The reason of the last dropped Learning summary choice the learner was told, so repeated clicks in one state raise one notice ({@link noteDroppedSummaryChoice}); forgotten at {@link open} and {@link close}. */ + private lastToldDroppedChoiceReason: string | undefined /** The editor's recent group and active text editor changes, for {@link refusePlacement}'s report (F382). */ private readonly placementEvents = new PlacementEventLog() /** The native effects this host itself issued (reveals, disposals, editor commands, shown documents), for {@link refusePlacement}'s report (N55-F4). */ @@ -8158,6 +8160,8 @@ export class StudyWorkspace { forAgent = false, ): Promise { if (origin !== 'presentation') this.assertPresentationIdle() + // A reader that opens again tells a dropped Learning summary choice anew (the notice is once per reason and state). + this.lastToldDroppedChoiceReason = undefined signal?.throwIfAborted() const lifetime = this.lifetime this.assertLifetime(lifetime) @@ -9579,6 +9583,7 @@ export class StudyWorkspace { // Read before anything is closed or shown: the tab outside the text editors and the Study panes that has the keyboard now (Cursor's chat), which gets it back when this close shows a side bar. const giveKeyboardBackToChat = this.retainForeignTabFocus(undefined) this.closing = true + this.lastToldDroppedChoiceReason = undefined this.revision++ this.lifetime++ this.pendingHistory = undefined @@ -11931,6 +11936,8 @@ export class StudyWorkspace { if (this.heldBackReplay?.entry === entry) { this.heldBackReplay = undefined this.lastPublished = undefined + // An entry that opened the reader and then failed or was superseded leaves the views on what the hold showed; they draw the retained replay again now. + if (this.opened && !this.closing && !this.disposed) this.publish() } } } @@ -23564,7 +23571,9 @@ export class StudyWorkspace { : {}), cause: `The "${parsed.data.type}" choice from the Learning summary was not carried out: ${reason}`, }) - // The learner sees the drop too: a pane that only released its list would otherwise show nothing happened. + // The learner sees the drop too, once per reason while the reader stays in that state; every drop above still has its Output entry. + if (this.lastToldDroppedChoiceReason === reason) return + this.lastToldDroppedChoiceReason = reason void showPlainNotice( vscode.window, 'information', diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index 0b6f5c43..c541e9f6 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -493,6 +493,39 @@ test('Manage Workspace Trust says where to find the command and records the caus ) }) +// Value: protects=the learner not being sent to the Command Palette for a trust command the editor has but could not open; fails_when=any rejection of workbench.trust.manage shows the Command Palette sentence as if the command were missing, or a failure that is not a missing command is not recorded in Output › Codebook (local review D5/RT3, Cycle 0056 pt3); why_new=the earlier test rejected only with a missing-command error; seam=the stubbed editor's command throwing a failure of its own; no_claim=the wording of a native editor's own error +test('Manage Workspace Trust says it could not open, and records the cause, when the editor has the command and it fails', async (context) => { + // Arrange + const { window } = await openPublishedWiki(context, { + trusted: false, + editorCommand: (name) => { + if (name === 'workbench.trust.manage') + throw new Error('The trust editor crashed') + }, + }) + + // Act + await window.runCommand('codebook.llmWiki.manageTrust') + + // Assert + assert.deepEqual( + [ + window.notices, + window.outputLines.some( + (line) => + line.startsWith('error: ') && + line.includes('The trust editor crashed'), + ), + ], + [ + [ + 'Manage Workspace Trust could not open. The cause is in Output › Codebook.', + ], + true, + ], + ) +}) + // Value: protects=AC 2 (error row); fails_when=a read that throws leaves the tree empty or on loading, writes no cause to Output, or does not recover at Refresh; no_claim=the cause's wording; seam=a folder that no longer exists test('a read that throws shows Could not read the LLM Wiki state, records the cause in Output and recovers at Refresh', async (context) => { // Arrange @@ -684,6 +717,8 @@ test('in a fresh project the watcher stands on the project and stays there once 6_000, ) await project.runCommand('codebook.refreshLlmWiki') + // The refresh settles before the cancel, so a slow re-registration of the watcher would already show in the count. + await project.llmWiki.whenIdle() await project.runCommand('codebook.llmWiki.cancelRun') // Assert diff --git a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts index 36b71008..cbd4fe71 100644 --- a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts +++ b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts @@ -1286,7 +1286,7 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo harness.focusMutableDraft('auth.ts', 3) harness.focusMutableDraft('actions.ts', 2) let arms = 0 - let sanitizedLabels = 0 + const sanitizedLabels = new Set() const reader = harness.reader as unknown as Record< string, (...args: unknown[]) => unknown @@ -1307,7 +1307,7 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo group.tabs.some((tab) => tab.label === args[0]), ) ) - sanitizedLabels++ + sanitizedLabels.add(String(args[0])) return sanitize(...args) }, ) @@ -1356,7 +1356,20 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo ).length, 1, ) - assert.equal(sanitizedLabels, 17) + // Every tab label the report names went through the sanitizer; the check follows the report's own lines, so a new report line needs no new count. + const cause = logged.find( + (entry) => entry.surface === 'presentation:placement-refused', + )!.cause + const labelsInReport = [ + ...new Set( + harness.groups.flatMap((group) => group.tabs.map((tab) => tab.label)), + ), + ].filter((label) => cause.includes(label)) + assert.equal(labelsInReport.length > 0, true) + assert.deepEqual( + labelsInReport.filter((label) => !sanitizedLabels.has(label)), + [], + ) } finally { context.mock.restoreAll() await harness.close() @@ -8707,6 +8720,104 @@ test('the wait for an Application group’s late activation ends at once when it } }) +/** + * Starts the reader's wait for an Application group's late activation report on the harness, with its tab-group subscriptions counted. + * @param harness - The workspace harness. + * @param signal - The preparation's cancellation, when the wait has one. + * @returns `wait` (settles when the wait ends), `isEnded()`, `reportActivation()` (the editor reports the group changed) and `subscriptions()` (how many listeners the wait registered and how many it disposed). + * @example const late = startLateActivationWait(harness); late.reportActivation(); await late.wait + */ +function startLateActivationWait( + harness: Awaited>, + signal?: AbortSignal, +) { + const identity = Symbol('allocated group') + const group = harness.groups[0]! + harness.reader['editorGroupIdentities'].set(group as never, identity) + const tabGroups = harness.editor.window.tabGroups + const subscribe = tabGroups.onDidChangeTabGroups.bind(tabGroups) + const counts = { registered: 0, disposed: 0 } + const listeners = new Set[0]>() + tabGroups.onDidChangeTabGroups = (callback) => { + counts.registered++ + listeners.add(callback) + const subscription = subscribe(callback) + return { + dispose(): void { + counts.disposed++ + listeners.delete(callback) + subscription.dispose() + }, + } + } + let isEnded = false + const wait = harness.reader['waitForLateGroupActivation']( + identity, + signal, + ).then(() => { + isEnded = true + }) + return { + wait, + isEnded: (): boolean => isEnded, + /** The editor reports the allocated group as changed, as Cursor does after the command answered. */ + reportActivation: (): void => { + for (const listener of [...listeners]) + listener({ opened: [], closed: [], changed: [group] }) + }, + subscriptions: () => ({ ...counts }), + } +} + +// Value: protects=the wait for an Application group's late activation ending at the first report, at its 150 ms bound when the editor reports nothing, and at once when its preparation is cancelled in any of those states, with its listener disposed each time; fails_when=a cancelled wait goes on to the bound or leaves its listener registered, a report does not end the wait, or the bound is not what ends a silent editor's wait; why_new=the coverage review found the wait's state cells (report, silence, cancellation) untested apart from an already aborted signal; seam=the reader's waitForLateGroupActivation on the workspace harness with the faked clock and counted tab-group listeners; no_claim=the native editor's report +for (const cell of [ + { name: 'a report of the group’s activation', ends: 'report' }, + { name: 'the 150 ms bound when the editor reports nothing', ends: 'bound' }, + { + name: 'a cancellation while the activation would still have come', + ends: 'abort', + reportsAfterwards: true, + }, + { + name: 'a cancellation while the editor reports nothing', + ends: 'abort', + reportsAfterwards: false, + }, +] as const) + test(`the wait for an Application group’s late activation ends at ${cell.name} and disposes its listener`, async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + const abort = new AbortController() + enableFakeClock(context) + const late = startLateActivationWait(harness, abort.signal) + const settledAfterEvents = async (): Promise => { + await Promise.resolve() + return late.isEnded() + } + assert.equal(await settledAfterEvents(), false) + + // Act + if (cell.ends === 'report') late.reportActivation() + if (cell.ends === 'bound') { + await advanceClock(context, 149) + assert.equal(await settledAfterEvents(), false) + await advanceClock(context, 1) + } + if (cell.ends === 'abort') abort.abort() + await late.wait + // A report after the wait ended changes nothing. + if ('reportsAfterwards' in cell && cell.reportsAfterwards) + late.reportActivation() + + // Assert + assert.deepEqual(late.subscriptions(), { registered: 1, disposed: 1 }) + } finally { + resetFakeClock(context) + await harness.close() + } + }) + for (const entry of [ "an agent's Study workspace request", 'Open Study Workspace', diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 3dc99b29..f15f7f65 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -262,7 +262,7 @@ test('a Saved studies choice sent from the Learning summary after the Study work surface: 'choice:dropped', studyId: history.studyId, cause: - 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. Open Study Workspace, then choose it again.', + 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.', }, ], ) @@ -4324,7 +4324,7 @@ for (const dropped of [ await harness.reader.close() }, sentence: - 'the Study workspace is closed. Open Study Workspace, then choose it again.', + 'the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.', }, { reason: 'the Study workspace is closing', @@ -4332,7 +4332,7 @@ for (const dropped of [ harness.reader['closing'] = true }, sentence: - 'the Study workspace is closing. Wait until it has closed, open it with Open Study Workspace, then choose it again.', + 'the Study workspace is closing. When it has closed, run Codebook: Open Study Workspace, then choose it again.', }, { reason: 'the Learning summary no longer shows a replay', @@ -4340,7 +4340,7 @@ for (const dropped of [ await harness.reader.action('return-current') }, sentence: - 'the Learning summary no longer shows a replay. Choose it again from Saved studies in the Study workspace.', + 'the Learning summary no longer shows a replay. Open a replay from Saved studies, then choose again.', }, ] as const) { // Value: protects=a Saved studies choice the Learning summary sends that the reader does not carry out telling the learner why in the words of that reason, in one notification and in one Output › Codebook entry; fails_when=the drop is recorded only in Output, or says the Study workspace is closed when it is closing or the summary left its replay (local open-code-review OCR-L4, Cycle 0056 pt3); why_new=task 17 recorded the drop but worded every reason as a closed workspace and showed the learner nothing; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor @@ -4439,7 +4439,7 @@ test('a Learning summary report that is no choice is dropped after Close Study W }, { newNotices: [ - 'Codebook did not carry out your choice: the Study workspace is closed. Open Study Workspace, then choose it again.', + 'Codebook did not carry out your choice: the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.', ], entries: ['choice:dropped'], }, @@ -4489,3 +4489,408 @@ test('a test replay whose Application group the editor activates after the prepa await harness.close() } }) + +/** + * Holds the reader's next `open()` at its start until the test releases it, so a test acts while an entry for a test replay waits in the closed reader. + * @param harness - The workspace harness. + * @returns `entered` (settles when the open began), `release()` (lets it go on) and `restore()` (puts the reader's own open back). + * @example const held = holdNextReaderOpen(harness); void harness.reader.openLearningReplay(id); await held.entered; held.release() + */ +function holdNextReaderOpen(harness: Harness) { + const open = harness.reader.open.bind(harness.reader) + let release = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const hasEntered = new Promise((resolve) => { + entered = resolve + }) + let isFirst = true + harness.reader.open = async (...openArguments): Promise => { + if (isFirst) { + isFirst = false + entered() + await gate + } + return open(...openArguments) + } + return { + entered: hasEntered, + release, + restore: (): void => { + harness.reader.open = open + }, + } +} + +/** + * Reads which replay the reader holds back from its views now. + * @param harness - The workspace harness. + * @returns The held-back replay's study id; `undefined` when nothing is held back. + * @example heldBackStudyId(harness) // => undefined + */ +function heldBackStudyId(harness: Harness): string | undefined { + return harness.reader['heldBackReplay']?.studyId +} + +// Value: protects=the replay a closed reader showed being drawn again in the Learning summary and the bottom panel when the entry for another replay opened the reader and then ended without showing its replay; fails_when=releasing the held-back replay publishes nothing, so both views stay on the opening state until an unrelated publish (local red team RT1, Cycle 0056 pt3); why_new=OCR-L3 kept the selection but left the views unpublished at the release; seam=the reader's openNodeHistory ending superseded after the open, the summary's and bottom panel's postMessage; no_claim=the native frames +test('an Open replay of another test replay that ends after it opened the closed reader draws the closed reader’s replay again in both views', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const chosenReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + harness.reader['openNodeHistory'] = async () => ({ + status: 'superseded' as const, + }) + const summaryDrawn = recordDrawnTestReplays(harness.summary), + bottomPanelDrawn = recordDrawnTestReplays(harness.playbackPanel) + + // Act + await harness.reader.openLearningReplay(chosenReplay.studyId) + + // Assert + assert.deepEqual( + [summaryDrawn.at(-1), bottomPanelDrawn.at(-1)], + [closedReplay.studyId, closedReplay.studyId], + ) + } finally { + await harness.close() + } +}) + +// Value: protects=one notification per reason for repeated dropped Learning summary choices, and a new notification once the workspace was opened and closed again, while every drop keeps its Output entry; fails_when=each repeated click raises another identical notification, or the notification is never told again after the workspace changed state (local red team RT2, Cycle 0056 pt3); why_new=OCR-L4 told every drop; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor +test('repeated Saved studies choices dropped for the same reason raise one notification, and a reopened then closed workspace tells again', async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + const droppedNotices = (): string[] => + harness.informationMessages + .map((notice) => notice.message) + .filter((message) => + message.startsWith('Codebook did not carry out your choice'), + ) + try { + const history = await openByOpenReplay(harness) + const choose = (openRequest: number, entries: number) => { + harness.receive({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + openRequest, + }) + return waitUntil( + () => logged.length === entries, + 'the dropped choice is recorded', + READER_RESULT_BOUND_MS, + ) + } + await harness.reader.close() + + // Act + await choose(1, 1) + await choose(2, 2) + const afterTwoChoices = droppedNotices().length + await harness.reader.open() + await harness.reader.close() + await choose(3, 3) + + // Assert + assert.deepEqual( + [afterTwoChoices, droppedNotices().length, logged.length], + [1, 2, 3], + ) + } finally { + await harness.close() + } +}) + +// Value: protects=what a test replay entry does to the replay a closed reader showed, per state of the entry (another replay or the same one, refused or superseded); fails_when=an entry for another replay leaves the closed reader's replay held back after it ended, loses it, or draws it while the other replay opens, or an entry for the same replay sets a hold at all (coverage review SM1 to SM3, Cycle 0056 pt3); why_new=the held-back replay was tested only for an entry that completed and one that was refused; seam=the reader's open held at its start, openLearningReplay, the summary's postMessage and the reader's heldBackReplay; no_claim=the native frames +for (const cell of [ + { + name: 'another replay whose entry a newer Open replay of a third replay supersedes', + entryFor: 'other', + ends: 'superseded by a newer entry', + holdsDuringEntry: true, + shownAfterwards: 'newer', + }, + { + name: 'another replay whose entry Return to Live supersedes', + entryFor: 'other', + ends: 'superseded by Return to Live', + holdsDuringEntry: true, + shownAfterwards: 'none', + }, + { + name: 'the same replay whose entry is refused', + entryFor: 'same', + ends: 'refused', + holdsDuringEntry: false, + shownAfterwards: 'closed', + }, + { + name: 'the same replay whose entry a newer Open replay of another replay supersedes', + entryFor: 'same', + ends: 'superseded by a newer entry', + holdsDuringEntry: false, + shownAfterwards: 'newer', + }, +] as const) + test(`an entry for ${cell.name} leaves the closed reader's replay ${cell.shownAfterwards === 'closed' ? 'for Open Study Workspace' : cell.shownAfterwards === 'none' ? 'retired by the return' : 'replaced by the newer replay'}, and draws it only when it is shown`, async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { + log: () => undefined, + show: () => undefined, + } + try { + const otherReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const newerReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const held = holdNextReaderOpen(harness) + const drawn = recordDrawnTestReplays(harness.summary) + const entry = harness.reader.openLearningReplay( + cell.entryFor === 'same' ? closedReplay.studyId : otherReplay.studyId, + ) + await held.entered + + // Act + const heldDuringEntry = heldBackStudyId(harness) + if (cell.ends === 'superseded by a newer entry') { + const newer = harness.reader.openLearningReplay(newerReplay.studyId) + held.release() + await Promise.all([entry, newer]) + await landsOn(harness, 2, 'the newer replay lands on its first stop') + } else if (cell.ends === 'superseded by Return to Live') { + const returned = harness.reader.action('return-current') + held.release() + await Promise.all([entry, returned]) + } else { + // Refused: the held open throws once released. + harness.reader.open = async (): Promise => { + throw new Error('The editor refused the arrangement.') + } + held.release() + await entry + held.restore() + } + const shownAfterwards = + harness.reader.inspect().view?.nodeHistory?.header.studyId + const heldAfterwards = heldBackStudyId(harness) + + // Assert + assert.deepEqual( + { + heldDuringEntry, + heldAfterwards, + shownAfterwards, + closedReplayDrawn: + cell.shownAfterwards === 'closed' + ? undefined + : drawn.includes(closedReplay.studyId), + }, + { + heldDuringEntry: cell.holdsDuringEntry + ? closedReplay.studyId + : undefined, + heldAfterwards: undefined, + shownAfterwards: + cell.shownAfterwards === 'closed' + ? closedReplay.studyId + : cell.shownAfterwards === 'newer' + ? newerReplay.studyId + : undefined, + closedReplayDrawn: + cell.shownAfterwards === 'closed' ? undefined : false, + }, + ) + } finally { + await harness.close() + } + }) + +// Value: protects=a browser recording the closed reader showed staying selected, with no replay held back, when a test replay entry is refused at the one-replay-at-a-time notice or superseded by a newer entry; fails_when=the entry sets a held-back replay for a browser recording, or drops the recording selection without the learner's answer (coverage review SM4 and SM5, Cycle 0056 pt3); why_new=the hold is set only for a retained test replay, and nothing pinned that a recording is left alone; seam=recordedReaderHarness, the unanswered warning dialog, the reader's heldBackReplay; no_claim=the notice's wording +for (const ending of ['refused', 'superseded'] as const) + test(`a test replay entry ${ending === 'refused' ? 'the learner leaves unanswered at the browser replay notice' : 'that a newer entry supersedes at the browser replay notice'} leaves the closed browser recording selected with no replay held back`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + await harness.reader.close() + // The first notice is dismissed; a newer entry's notice is dismissed too, so the recording stays. + harness.answerWarningDialogs(async () => undefined) + + // Act + const first = harness.reader.openLearningReplay(history.studyId) + const second = + ending === 'superseded' + ? harness.reader.openLearningReplay(history.studyId) + : Promise.resolve() + await Promise.all([first, second]) + + // Assert + assert.deepEqual( + { + held: heldBackStudyId(harness), + selectedStudy: harness.reader.inspect().view?.model.selection.studyId, + }, + { held: undefined, selectedStudy: harness.studyId }, + ) + } finally { + await harness.close() + } + }) + +/** + * Holds the reader's next `initialize()` until the test releases it, so a Learning summary message waits between the reader's two owner checks. + * @param harness - The workspace harness. + * @returns `entered` (settles when the message reached `initialize()`) and `release()`. + * @example const held = holdNextInitialize(harness); harness.receive(message); await held.entered; await harness.reader.close(); held.release() + */ +function holdNextInitialize(harness: Harness) { + const initialize = harness.reader['initialize'].bind(harness.reader) + let release = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const hasEntered = new Promise((resolve) => { + entered = resolve + }) + let isFirst = true + harness.reader['initialize'] = async (): Promise => { + if (isFirst) { + isFirst = false + entered() + await gate + } + return initialize() + } + return { entered: hasEntered, release } +} + +// Value: protects=a Learning summary message dropped because the workspace closed while the reader was initializing: a choice tells the learner once and records one Output entry and releases the pane, a report that is no choice is dropped quietly; fails_when=the second owner check drops silently or tells for a message that is no choice (coverage review SM6 and SM7, Cycle 0056 pt3); why_new=the drop after initialize() was untested; seam=workspaceHarness, the reader's initialize held, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look +for (const message of [ + { name: 'a Saved studies choice', isChoice: true }, + { name: 'a report that is no choice', isChoice: false }, +] as const) + test(`${message.name} sent from the Learning summary before the workspace closed while the reader initialized is ${message.isChoice ? 'told once and recorded' : 'dropped quietly'}`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const history = await openByOpenReplay(harness) + const noticesBefore = harness.informationMessages.length + const held = holdNextInitialize(harness) + const received = readerSeams(harness).receiveFromPanel( + message.isChoice + ? { + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + openRequest: 11, + } + : { type: 'ready' }, + 'context', + harness.summary, + ) + await held.entered + + // Act: the workspace closes while the message waits, then the reader goes on. + await harness.reader.close() + held.release() + await received + + // Assert + assert.deepEqual( + { + newNotices: harness.informationMessages + .slice(noticesBefore) + .map((notice) => notice.message) + .filter((text) => + text.startsWith('Codebook did not carry out your choice'), + ).length, + entries: logged.map((entry) => entry.surface), + }, + message.isChoice + ? { newNotices: 1, entries: ['choice:dropped'] } + : { newNotices: 0, entries: [] }, + ) + } finally { + await harness.close() + } + }) + +// Value: protects=a message of a Study pane that a handoff retired being dropped without a notification or a choice:dropped entry, whether the handoff came before the message or while the reader initialized; fails_when=a retired Study pane's choice tells the learner or writes the Learning summary's drop entry (coverage review SM8 and SM9, Cycle 0056 pt3); why_new=only the Learning summary's drop was tested; seam=readerSeams.receiveFromPanel with a pane that is not the reader's Study pane, the reader's initialize held, the information messages, the diagnostics log; no_claim=the replacement pane's behaviour +for (const handoff of ['before', 'while'] as const) + test(`a choice from a Study pane that a handoff retired ${handoff === 'before' ? 'before' : 'while'} the reader initialized is dropped without a notification or an entry`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + await harness.reader.open() + const studyPane = harness.reader['contextPanel'] + const noticesBefore = harness.informationMessages.length + const message = { + type: 'tab', + revision: lastView(harness)!.revision, + tab: 'live', + } + + // Act + if (handoff === 'before') { + await readerSeams(harness).receiveFromPanel(message, 'context', {}) + } else { + const held = holdNextInitialize(harness) + const received = readerSeams(harness).receiveFromPanel( + message, + 'context', + studyPane, + ) + await held.entered + // The handoff: another pane replaces the Study pane while the reader initializes. + harness.reader['contextPanel'] = {} as never + held.release() + await received + harness.reader['contextPanel'] = studyPane + } + + // Assert + assert.deepEqual( + { + newNotices: harness.informationMessages.length - noticesBefore, + entries: logged.map((entry) => entry.surface), + }, + { newNotices: 0, entries: [] }, + ) + } finally { + await harness.close() + } + }) diff --git a/extension/src/utils/chooseWikiWatchFolder.ts b/extension/src/utils/chooseWikiWatchFolder.ts index a809d22a..8fda20fd 100644 --- a/extension/src/utils/chooseWikiWatchFolder.ts +++ b/extension/src/utils/chooseWikiWatchFolder.ts @@ -22,6 +22,8 @@ type WikiWatchChoice = { * * Inside the project the watcher stands on the project root, so a delete or move of any folder from the root down to the output (the folder holding the Wiki included) is reported; the editor reports nothing for the watched folder itself. For an output outside the project it stands only on the output's own parent when that exists, so deleting that parent is not seen there: its ancestor could be the home folder or a root, which are never watched. When the project root is the home folder or a root, the nearest existing folder at or below it is used instead. * + * Residual: the editor reports nothing for the watched folder itself, so deleting or renaming the project folder (or a folder above it) is not seen until Refresh or the next time the view is shown. When the project root is the home folder or a root, the nearest existing folder is watched and deleting that folder is seen the same way. + * * @param choice - The project, the output, the nearest existing folder and the home folder. * @returns The folder to watch; `undefined` for the no-watcher fallback (the view then refreshes on visibility and its Refresh action only). * @example diff --git a/extension/src/utils/placementRefusalReport.ts b/extension/src/utils/placementRefusalReport.ts index 9d3356ed..6fe4e197 100644 --- a/extension/src/utils/placementRefusalReport.ts +++ b/extension/src/utils/placementRefusalReport.ts @@ -28,7 +28,6 @@ export type RefusalEvent = { /** * Keeps the last notes (the editor's tab-group changes, tab operations, active text editor changes and pane view-state changes, or the effects Codebook itself issued) with their time, so a placement refusal can say what happened while its guard waited; fed by {@link StudyWorkspace}'s listeners and effect wrappers and read by {@link describePlacementRefusal}. - * @param capacity - How many notes are kept; the editor's events keep {@link REFUSAL_REPORT_EVENTS}, Codebook's own effects `REFUSAL_REPORT_EFFECTS` (set by the caller). * @example * const log = new PlacementEventLog() * log.record(10, 'groups closed [2]') @@ -36,6 +35,10 @@ export type RefusalEvent = { export class PlacementEventLog { private readonly events: RefusalEvent[] = [] + /** + * Creates a log that keeps the last `capacity` notes. + * @param capacity - How many notes are kept; the editor's events keep {@link REFUSAL_REPORT_EVENTS} (the default), Codebook's own effects {@link REFUSAL_REPORT_EFFECTS} (set by the caller). + */ constructor(private readonly capacity: number = REFUSAL_REPORT_EVENTS) {} /** Adds one note, dropping the oldest beyond the capacity. @example log.record(performance.now(), 'groups opened [3]') */ diff --git a/extension/src/utils/registerLlmWikiCommands.ts b/extension/src/utils/registerLlmWikiCommands.ts index 57e54eaa..c0a46b66 100644 --- a/extension/src/utils/registerLlmWikiCommands.ts +++ b/extension/src/utils/registerLlmWikiCommands.ts @@ -5,6 +5,7 @@ import type * as vscode from 'vscode' import { COPY_DETAILS_ACTION, COPY_WIKI_RECOVERY_REQUEST_COMMAND, + LLM_WIKI_MANAGE_TRUST_FAILED_NOTICE, LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE, llmWikiFolderLocationFailedNotice, LLM_WIKI_RECOVERY_REQUEST_COPIED_NOTICE, @@ -79,22 +80,27 @@ async function showRowDetail( } /** - * Runs the editor's Manage Workspace Trust command, or says where to find it when the editor rejects it. + * Runs the editor's Manage Workspace Trust command; says where to find it when the editor lacks the command, and that the command could not open when it rejects for another cause. * * Trigger: the Manage Workspace Trust action of the view's trust row ({@link registerLlmWikiCommands}). * @param options - The window's closures. - * @returns Nothing; a rejection is recorded in Output › Codebook and the learner gets the Command Palette notice. + * @returns Nothing; a rejection is recorded in Output › Codebook; the learner gets the Command Palette notice for a missing command and the could-not-open notice for any other rejection. * @example await manageTrust(options) */ async function manageTrust(options: LlmWikiCommandOptions): Promise { try { await options.editor.commands.executeCommand(MANAGE_WORKSPACE_TRUST_COMMAND) } catch (cause) { - // An editor without the command rejects; the learner still learns where to trust the folder. options.recordFailure('Manage Workspace Trust could not run', cause) + // The editor says "command '' not found" when it lacks the command; only then the learner learns where else to trust the folder. Any other rejection is a command that exists and failed. + const lacksCommand = thrownMessage(cause).includes( + `command '${MANAGE_WORKSPACE_TRUST_COMMAND}' not found`, + ) void options.showNotice( 'information', - LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE, + lacksCommand + ? LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE + : LLM_WIKI_MANAGE_TRUST_FAILED_NOTICE, ) } } From eb9c9b110908ef9c9e030170dcd7d053a510e79f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 13:03:16 +0900 Subject: [PATCH 21/31] fix: Cycle 0056 pt3: pre-landing review fixes, pass 2 (hold kept until the last entry ends, dismissable drop notice, trust failure by command list, sanitized Wiki failures, dropped-choice state cells) --- extension/README.md | 6 +- extension/src/constants.ts | 6 +- extension/src/extension.ts | 13 +- extension/src/studyWorkspace.ts | 118 +++- extension/src/tests/llmWikiView.test.ts | 28 +- .../studyWorkspaceEntryObstacles.test.ts | 7 +- ...tudyWorkspaceTestReplayComposition.test.ts | 642 ++++++++++++------ extension/src/tests/utils/wikiUpdateWindow.ts | 3 +- .../src/utils/registerLlmWikiCommands.ts | 16 +- 9 files changed, 583 insertions(+), 256 deletions(-) diff --git a/extension/README.md b/extension/README.md index 0207f2a6..b6abe258 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,9 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address, also in an editor that rebuilds its tab objects. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there no longer refuses because the editor reported your next tab as the active one. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice made in the Learning summary while the Study workspace is closed, closing, or no longer showing a replay is not carried out. Codebook tells you why in one notification that starts `Codebook did not carry out your choice:` and ends with the reason: `the Study workspace is closed` (run **Codebook: Open Study Workspace**, then choose again), `the Study workspace is closing` (run it when it has closed), or `the Learning summary no longer shows a replay` (open a replay from Saved studies, then choose again). You are told once per reason while the workspace stays in that state; opening or closing the workspace lets the next dropped choice tell again. **Output › Codebook** records one `choice:dropped` entry with the reason for every dropped choice, naming the study. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. + +A choice you make in the Learning summary that Codebook cannot carry out is not lost silently: the notice names the reason and what to do next. The notice starts `Codebook did not carry out your choice:` and ends with one of three reasons. `the Study workspace is closed` asks you to run **Codebook: Open Study Workspace**, then choose it again. `the Study workspace is closing` asks you to run it when it has closed, then choose it again. `the Learning summary no longer shows that replay` (the workspace is open but shows the Live arrangement or the Study pane) asks you to open a replay from Saved studies, then choose it again. While a notice for a reason is still open, a repeated choice raises no second one; once you dismiss it, or when the workspace opens, closes or enters the learning-replay composition, the next dropped choice tells again. **Output › Codebook** records one `choice:dropped` entry with the reason for every dropped choice, naming the study. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. @@ -244,7 +246,7 @@ For tools: `GET /api/update-status` answers the current state as JSON (` The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site of the opened project stands and the next step; it sits below the **Repository Wiki** view, which lists repository articles, not the generated site. Its rows come and go with the facts they show, in a fixed order, and the tooltip of a row has its full text: -- With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. +- With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. If the editor has no Manage Workspace Trust command the view points to "Workspaces: Manage Workspace Trust" in the Command Palette; if the command fails, the view says it could not open, names that command, and the cause is in **Output › Codebook**. - The view follows its Wiki folder on disk: creating the folder, or deleting or moving it or any folder above it inside your project (for example `rm -rf .codebook`), refreshes the rows within a second, and a deleted Wiki brings back the welcome without **Refresh**. A Wiki folder outside the project is watched from its own parent folder only, so deleting that parent shows after **Refresh** or the next time the view is shown (Codebook never watches your home folder or a drive root). - In a folder with no Wiki and no run the view shows a welcome with a **Generate LLM Wiki** button, what runs it in your editor (Copilot Chat in VS Code, `cursor-agent` in Cursor) and the output folder with a **Change location** link (`The Wiki is written to .codebook/wiki unless the setting codebook.wikiOutputDirectory names another folder.`). A click on the **Output** row opens the same setting. - **` Wiki`** is the published Wiki. A click opens it in the browser. Its description is the freshness label of the reader page (`Wiki source current`, `Source changed · Update available`, `Wiki freshness unknown: …`, or `Checking freshness…` while the check runs), and its tooltip names the generation: `Generated from on `, `Generated on (no Git revision)` in a folder without Git, or `Generated from ` when the date cannot be read; when the Wiki records no Git revision, the tooltip also says in full that added files are not checked, which the description may cut off in a narrow sidebar. **Update LLM Wiki** is its action while no run shows. The row stays while a run works, so you can read the Wiki during an update. diff --git a/extension/src/constants.ts b/extension/src/constants.ts index a58ee1bb..9fbc165b 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1072,7 +1072,7 @@ export const LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE = /** What the LLM Wiki view says when the editor has the Manage Workspace Trust command ({@link MANAGE_WORKSPACE_TRUST_COMMAND}) but it failed to open; the cause is in Output › Codebook. */ export const LLM_WIKI_MANAGE_TRUST_FAILED_NOTICE = - 'Manage Workspace Trust could not open. The cause is in Output › Codebook.' + 'Manage Workspace Trust could not open. Try "Workspaces: Manage Workspace Trust" from the Command Palette; the cause is in Output › Codebook.' /** What the LLM Wiki view says once the recovery request is copied. */ export const LLM_WIKI_RECOVERY_REQUEST_COPIED_NOTICE = @@ -1861,7 +1861,7 @@ export const LATE_GROUP_ACTIVATION_WAIT_MS = 150 /** Why the reader did not carry out a choice the Learning summary sent while the Study workspace was closed; completes "The … choice from the Learning summary was not carried out: " in Output › Codebook and "Codebook did not carry out your choice: " in the notice the learner sees. */ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSED = - 'the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.' + 'the Study workspace is closed. To continue, run Codebook: Open Study Workspace, then choose it again.' /** The reason when the choice arrived while the Study workspace was closing. See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSING = @@ -1869,7 +1869,7 @@ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSING = /** The reason when the reader had left the summary composition (the Study pane stands again). See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ export const SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT = - 'the Learning summary no longer shows a replay. Open a replay from Saved studies, then choose again.' + 'the Learning summary no longer shows that replay. Open a replay from Saved studies, then choose it again.' /** What the learner's notice for a dropped Learning summary choice starts with, before the reason. */ export const SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX = diff --git a/extension/src/extension.ts b/extension/src/extension.ts index c7d93644..a01f5802 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -88,6 +88,7 @@ import { import { assertNoLinkOnWikiPath } from './utils/assertNoLinkOnWikiPath' import { buildWikiGeneratePrompt } from './utils/buildWikiGeneratePrompt' import { buildWikiUpdatePrompt } from './utils/buildWikiUpdatePrompt' +import { causeRootsFor } from './utils/causeRootsFor' import { claimWikiUpdateMarker, type TakenOverWikiReservation, @@ -163,6 +164,7 @@ import { resolveDeepWikiSource } from './utils/resolveDeepWikiSource' import { resolveExternalNodeExecutable } from './utils/resolveExternalNodeExecutable' import { runCursorWikiUpdate } from './utils/runCursorWikiUpdate' import { runUnderAbortSignal } from './utils/runUnderAbortSignal' +import { sanitizeCause } from './utils/sanitizeCause' import { wikiLastRunSchema, type WikiLastRun, @@ -3186,7 +3188,16 @@ export function activate( /** Writes a failure of the LLM Wiki view or its commands to Output › Codebook; the view and Reveal Wiki Folder use it. */ const recordLlmWikiFailure = (message: string, cause: unknown): void => { try { - output?.error(`${message}: ${thrownMessage(cause)}`) + // The cause is worded as the Study workspace's are: the home directory, storage and repository read `~`, `` and ``, and control characters become spaces. + output?.error( + sanitizeCause( + `${message}: ${thrownMessage(cause)}`, + causeRootsFor( + [context.globalStorageUri.fsPath], + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + ), + ), + ) } catch { // A disposed channel has nowhere to write. } diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 502a0c0a..9d85b91b 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -742,6 +742,12 @@ type NodeHistoryPositionWriter = { running?: Promise } +/** The editor commands {@link StudyWorkspace.executeEditorCommand} runs without noting them as Codebook's own effects, because they change nothing: the layout read and the context-key write. */ +const UNNOTED_EDITOR_COMMANDS: ReadonlySet = new Set([ + 'vscode.getEditorLayout', + 'setContext', +]) + /** Owns the retained learner reader while {@link StudyService} owns execution; activation constructs it before MCP-first runs. @example await workspace.open() */ export class StudyWorkspace { private service?: StudyService @@ -988,11 +994,13 @@ export class StudyWorkspace { /** `true` while {@link openNodeHistoryFromHost} enters the summary composition for a test replay that has not begun opening ({@link testReplayHoldsComposition}). */ private hostOpeningTestReplay = false /** - * The replay a closed reader keeps selected for its next Open Study Workspace while an entry for another replay opens; the Learning summary and the bottom panel receive no message that carries it ({@link publish}). It is released when the entry ends ({@link openNodeHistoryFromHost}), and it holds nothing once the chosen replay replaces the selection. + * The replay a closed reader keeps selected for its next Open Study Workspace while an entry for another replay opens; the Learning summary and the bottom panel receive no message that carries it ({@link publish}). It is released when the last entry in progress ends ({@link openNodeHistoryFromHost}, {@link nodeHistoryEntriesInProgress}) or at {@link close}, and it holds nothing once the chosen replay replaces the selection. */ - private heldBackReplay: { entry: number; studyId: string } | undefined - /** The reason of the last dropped Learning summary choice the learner was told, so repeated clicks in one state raise one notice ({@link noteDroppedSummaryChoice}); forgotten at {@link open} and {@link close}. */ - private lastToldDroppedChoiceReason: string | undefined + private heldBackReplay: { studyId: string } | undefined + /** How many test replay entries ({@link openNodeHistoryFromHost}) have not ended yet, from the turn they wait for to their open's end; the held-back replay is released when the last one ends. */ + private nodeHistoryEntriesInProgress = 0 + /** The last notice told for a dropped Learning summary choice, with whether the learner has not dismissed it yet: a repeat of its reason raises no second notice while it is open ({@link noteDroppedSummaryChoice}); forgotten at {@link open}, {@link close} and when the summary composition is entered. */ + private droppedChoiceNotice: { reason: string; isOpen: boolean } | undefined /** The editor's recent group and active text editor changes, for {@link refusePlacement}'s report (F382). */ private readonly placementEvents = new PlacementEventLog() /** The native effects this host itself issued (reveals, disposals, editor commands, shown documents), for {@link refusePlacement}'s report (N55-F4). */ @@ -8161,7 +8169,7 @@ export class StudyWorkspace { ): Promise { if (origin !== 'presentation') this.assertPresentationIdle() // A reader that opens again tells a dropped Learning summary choice anew (the notice is once per reason and state). - this.lastToldDroppedChoiceReason = undefined + this.droppedChoiceNotice = undefined signal?.throwIfAborted() const lifetime = this.lifetime this.assertLifetime(lifetime) @@ -8281,6 +8289,8 @@ export class StudyWorkspace { this.presentationConfirmed = false this.liveComposition = liveComposition this.summaryComposition = summaryComposition + // Entering the summary composition tells a dropped choice anew. + if (summaryComposition) this.droppedChoiceNotice = undefined } if (this.liveComposition || this.summaryComposition) { // Live uses the host chat; disposing only our panel never closes another editor's tabs. @@ -9583,7 +9593,8 @@ export class StudyWorkspace { // Read before anything is closed or shown: the tab outside the text editors and the Study panes that has the keyboard now (Cursor's chat), which gets it back when this close shows a side bar. const giveKeyboardBackToChat = this.retainForeignTabFocus(undefined) this.closing = true - this.lastToldDroppedChoiceReason = undefined + this.droppedChoiceNotice = undefined + this.heldBackReplay = undefined this.revision++ this.lifetime++ this.pendingHistory = undefined @@ -11885,6 +11896,7 @@ export class StudyWorkspace { ): Promise { const entry = this.supersedeNodeHistoryEntries(), overtaken = this.nodeHistoryEntryCancellation.signal + this.nodeHistoryEntriesInProgress++ try { // Entries take turns entering the composition; a newer entry made meanwhile is the learner's latest choice (EN19). const previousTurn = this.nodeHistoryEntryTurn @@ -11932,12 +11944,19 @@ export class StudyWorkspace { ) return this.openNodeHistoryFromHost(studyId) } finally { - // The closed reader's replay held back for this entry is drawn again, by the next Open Study Workspace if this entry did not replace it (Cycle 0056 task 32). - if (this.heldBackReplay?.entry === entry) { + // The closed reader's replay held back for the entries is drawn again, by the next Open Study Workspace if no entry replaced it; a newer entry still in progress keeps the hold, so the views never draw the retained replay while it loads (Cycle 0056 task 32). + this.nodeHistoryEntriesInProgress-- + if (this.nodeHistoryEntriesInProgress === 0 && this.heldBackReplay) { this.heldBackReplay = undefined this.lastPublished = undefined - // An entry that opened the reader and then failed or was superseded leaves the views on what the hold showed; they draw the retained replay again now. - if (this.opened && !this.closing && !this.disposed) this.publish() + // An entry that opened the reader and then failed or was superseded leaves the views on what the hold showed; they draw the retained replay again now. A failing publish is reported and never replaces the entry's own result. + if (this.opened && !this.closing && !this.disposed) { + try { + this.publish() + } catch (error) { + this.report('control', error) + } + } } } } @@ -12092,7 +12111,6 @@ export class StudyWorkspace { this.selectedNodeHistory.header.studyId !== studyId ) this.heldBackReplay = { - entry, studyId: this.selectedNodeHistory.header.studyId, } // The open's waits for the work window end too when a newer entry, a Return or a Close supersedes this one (Cycle 0052 task 44). @@ -13921,6 +13939,25 @@ export class StudyWorkspace { return this.recordingReloadStudyId !== undefined ? 'browser' : undefined } + /** + * Posts a published message to the Learning summary and the bottom panel; called by {@link publish}. + * The Learning summary draws only a shown replay: a publish while the reader leaves one would draw the Live Study page there (Cycle 0052 task 2, N51-V2). A replay held back for another replay's entry ({@link heldBackReplay}) is drawn by neither view (Cycle 0056 task 32). + * @param compact - The message {@link publish} built. + * @example this.postToReplayViews(compact) + */ + private postToReplayViews(compact: StudyViewMessage): void { + const isHeldBack = + this.heldBackReplay !== undefined && + this.selectedNodeHistory?.header.studyId === this.heldBackReplay.studyId + if (this.summaryComposition && compact.replayFamily && !isHeldBack) + void this.summaryView?.webview.postMessage(compact) + if (this.moments) + this.moments.title = this.summaryComposition + ? 'Learning replay' + : 'Observed moments' + if (!isHeldBack) void this.moments?.webview.postMessage(compact) + } + /** Publishes a matching projection with stable DOM controls, excluding images from the context channel. @example this.publish() */ private publish(): void { this.publishLearningSave() @@ -13956,18 +13993,7 @@ export class StudyWorkspace { if (signature === this.lastPublished) return this.lastPublished = signature void this.contextPanel?.webview.postMessage(compact) - // The Learning summary draws only a shown replay: a publish while the reader leaves one would draw the Live Study page there (Cycle 0052 task 2, N51-V2). - // A replay held back for another replay's entry is drawn by no view (Cycle 0056 task 32). - const isHeldBack = - this.heldBackReplay !== undefined && - this.selectedNodeHistory?.header.studyId === this.heldBackReplay.studyId - if (this.summaryComposition && compact.replayFamily && !isHeldBack) - void this.summaryView?.webview.postMessage(compact) - if (this.moments) - this.moments.title = this.summaryComposition - ? 'Learning replay' - : 'Observed moments' - if (!isHeldBack) void this.moments?.webview.postMessage(compact) + this.postToReplayViews(compact) if (this.outline) this.outline.message = message.model.currentSourceAvailable ? `${message.model.understoodCount} of ${message.model.progressByItem.length} understood for current source · ${message.model.reviewCount} needs review` @@ -22467,6 +22493,7 @@ export class StudyWorkspace { callSite?: string armedAtMs: number before: RefusalGroupView[] + reportsFocus: true focusedTab: RefusalTabView | undefined } { return { @@ -22474,6 +22501,7 @@ export class StudyWorkspace { ...(callSite === undefined ? {} : { callSite }), armedAtMs: performance.now(), before: this.refusalGroupViews(), + reportsFocus: true, focusedTab: this.refusalTabView( vscode.window.tabGroups.activeTabGroup.activeTab, ), @@ -22486,6 +22514,10 @@ export class StudyWorkspace { * @returns The view, or `undefined`; the label still needs {@link sanitizeRefusalGroups}'s treatment before it is shown. * @example this.refusalTabView(group.activeTab) // => { label: 'auth.ts', kind: 'text', isPreview: false, isPinned: false } */ + private refusalTabView(tab: vscode.Tab): RefusalTabView + private refusalTabView( + tab: vscode.Tab | undefined, + ): RefusalTabView | undefined private refusalTabView( tab: vscode.Tab | undefined, ): RefusalTabView | undefined { @@ -22518,7 +22550,7 @@ export class StudyWorkspace { viewColumn: group.viewColumn, isActive: group === active, tabs: group.tabs.slice(0, REFUSAL_REPORT_GROUP_TABS).map((tab) => { - const { label, kind } = this.refusalTabView(tab)! + const { label, kind } = this.refusalTabView(tab) return { label, kind } }), ...(activeTab ? { activeTab } : {}), @@ -22625,6 +22657,8 @@ export class StudyWorkspace { callSite?: string armedAtMs: number before: RefusalGroupView[] + /** `true` for an arm that noted the focused tab ({@link armPlacementReport}); the report then lists the focus at arming and at refusal. */ + reportsFocus?: true focusedTab?: RefusalTabView | undefined }, failedChecks?: readonly string[], @@ -22638,7 +22672,7 @@ export class StudyWorkspace { nowMs, before: this.sanitizeRefusalGroups(armed.before), after: this.sanitizeRefusalGroups(this.refusalGroupViews()), - ...('focusedTab' in armed + ...(armed.reportsFocus ? { focus: { atArming: this.sanitizeRefusalTab(armed.focusedTab), @@ -22729,7 +22763,7 @@ export class StudyWorkspace { } /** - * Runs an editor command and notes it as one of Codebook's own effects; every command this class sends goes through here, instead of `vscode.commands.executeCommand`, except the reads of the layout and context keys, which change nothing. + * Runs an editor command and notes it as one of Codebook's own effects; every command this class sends goes through here, instead of `vscode.commands.executeCommand`. The layout read and `setContext` are run the same way but are not noted as effects, because they change nothing ({@link UNNOTED_EDITOR_COMMANDS}). * @param command - The command id. * @param rest - Its arguments. * @returns What the editor's command returns. @@ -22739,7 +22773,7 @@ export class StudyWorkspace { command: string, ...rest: unknown[] ): Thenable { - if (command !== 'vscode.getEditorLayout' && command !== 'setContext') + if (!UNNOTED_EDITOR_COMMANDS.has(command)) this.noteNativeEffect(`command ${command}`) return vscode.commands.executeCommand(command, ...rest) } @@ -22785,7 +22819,7 @@ export class StudyWorkspace { } /** - * Disposes an owned pane and notes it as one of Codebook's own effects, with the group tabs it leaves listed by the editor. + * Disposes an owned pane and notes it as one of Codebook's own effects, with the pane's type and column ({@link describePaneIdentity}). * @param panel - The pane; `undefined` disposes nothing. * @example this.disposePane(this.contextPanel) */ @@ -23571,13 +23605,27 @@ export class StudyWorkspace { : {}), cause: `The "${parsed.data.type}" choice from the Learning summary was not carried out: ${reason}`, }) - // The learner sees the drop too, once per reason while the reader stays in that state; every drop above still has its Output entry. - if (this.lastToldDroppedChoiceReason === reason) return - this.lastToldDroppedChoiceReason = reason - void showPlainNotice( - vscode.window, - 'information', - `${SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX}${reason}`, + // The learner sees the drop too, once while the notice for this reason stays open; once it is dismissed the next drop tells again. Every drop above still has its Output entry. + if ( + this.droppedChoiceNotice?.reason === reason && + this.droppedChoiceNotice.isOpen + ) + return + const told = { reason, isOpen: true } + this.droppedChoiceNotice = told + void Promise.resolve( + showPlainNotice( + vscode.window, + 'information', + `${SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX}${reason}`, + ), + ).then( + () => { + told.isOpen = false + }, + () => { + told.isOpen = false + }, ) } diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index c541e9f6..b01012e6 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -493,7 +493,7 @@ test('Manage Workspace Trust says where to find the command and records the caus ) }) -// Value: protects=the learner not being sent to the Command Palette for a trust command the editor has but could not open; fails_when=any rejection of workbench.trust.manage shows the Command Palette sentence as if the command were missing, or a failure that is not a missing command is not recorded in Output › Codebook (local review D5/RT3, Cycle 0056 pt3); why_new=the earlier test rejected only with a missing-command error; seam=the stubbed editor's command throwing a failure of its own; no_claim=the wording of a native editor's own error +// Value: protects=the learner told that a trust command the editor has could not open, with the Command Palette route and the cause in Output; fails_when=any rejection of workbench.trust.manage shows the missing-command sentence, the failure notice names no route, or the editor's own wording of the error decides which notice shows (local review D5/RT3, /ship pass 2 M3 and D8, Cycle 0056 pt3); why_new=the earlier test rejected only with a missing-command error; seam=the stubbed editor's command throwing a failure of its own; no_claim=the wording of a native editor's own error test('Manage Workspace Trust says it could not open, and records the cause, when the editor has the command and it fails', async (context) => { // Arrange const { window } = await openPublishedWiki(context, { @@ -519,13 +519,37 @@ test('Manage Workspace Trust says it could not open, and records the cause, when ], [ [ - 'Manage Workspace Trust could not open. The cause is in Output › Codebook.', + 'Manage Workspace Trust could not open. Try "Workspaces: Manage Workspace Trust" from the Command Palette; the cause is in Output › Codebook.', ], true, ], ) }) +// Value: protects=the Output entry of a failed Manage Workspace Trust never carrying the home directory or a control character that the editor's error held; fails_when=the cause reaches Output as the editor worded it (/ship pass 2 S1, Cycle 0056 pt3); why_new=the view's failure recorder wrote the raw error text; seam=the stubbed editor's trust command throwing a message with the home path and a bell character; no_claim=the wording of a native editor's own error +test('a failed Manage Workspace Trust records its cause in Output without the home directory or control characters', async (context) => { + // Arrange + const home = homedir() + const { window } = await openPublishedWiki(context, { + trusted: false, + editorCommand: (name) => { + if (name === 'workbench.trust.manage') + throw new Error(`Failed at ${home}/private/trust.json\u0007`) + }, + }) + + // Act + await window.runCommand('codebook.llmWiki.manageTrust') + + // Assert + const line = window.outputLines.find((entry) => + entry.includes('Manage Workspace Trust could not run'), + ) + assert.equal(line?.includes('~/private/trust.json'), true) + assert.equal(line?.includes(home), false) + assert.equal(line?.includes('\u0007'), false) +}) + // Value: protects=AC 2 (error row); fails_when=a read that throws leaves the tree empty or on loading, writes no cause to Output, or does not recover at Refresh; no_claim=the cause's wording; seam=a folder that no longer exists test('a read that throws shows Could not read the LLM Wiki state, records the cause in Output and recovers at Refresh', async (context) => { // Arrange diff --git a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts index cbd4fe71..2cc6cf50 100644 --- a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts +++ b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts @@ -1287,6 +1287,7 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo harness.focusMutableDraft('actions.ts', 2) let arms = 0 const sanitizedLabels = new Set() + let sanitizedLabelCalls = 0 const reader = harness.reader as unknown as Record< string, (...args: unknown[]) => unknown @@ -1306,8 +1307,10 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo harness.groups.some((group) => group.tabs.some((tab) => tab.label === args[0]), ) - ) + ) { + sanitizedLabelCalls++ sanitizedLabels.add(String(args[0])) + } return sanitize(...args) }, ) @@ -1356,6 +1359,8 @@ test('a refused placement guard sanitizes the tab labels of its two snapshots fo ).length, 1, ) + // The exact number of label sanitizations stays pinned beside the set check: a report that sanitizes more or fewer labels than before is a change to review. + assert.equal(sanitizedLabelCalls, 17) // Every tab label the report names went through the sanitizer; the check follows the report's own lines, so a new report line needs no new count. const cause = logged.find( (entry) => entry.surface === 'presentation:placement-refused', diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index f15f7f65..2d8480d1 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { + LATE_GROUP_ACTIVATION_WAIT_MS, NODE_TEST_HISTORY_DIRECTORY, REPOSITORY_BROWSER_PROFILE as PROFILE_ID, } from '../constants' @@ -262,7 +263,7 @@ test('a Saved studies choice sent from the Learning summary after the Study work surface: 'choice:dropped', studyId: history.studyId, cause: - 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.', + 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. To continue, run Codebook: Open Study Workspace, then choose it again.', }, ], ) @@ -4317,139 +4318,220 @@ test('a refused Open replay of another test replay after Close leaves the closed } }) -for (const dropped of [ +/** What the learner is told, and the Output entry says, for each reason a Learning summary choice is dropped. */ +const DROPPED_CHOICE_SENTENCES = { + closed: + 'the Study workspace is closed. To continue, run Codebook: Open Study Workspace, then choose it again.', + closing: + 'the Study workspace is closing. When it has closed, run Codebook: Open Study Workspace, then choose it again.', + left: 'the Learning summary no longer shows that replay. Open a replay from Saved studies, then choose it again.', +} as const + +/** What a dropped-choice message is built from. */ +type DroppedChoiceContext = { + revision: number + studyId: string + itemId: string +} + +/** The four choices the Learning summary sends that a closed, closing or left composition drops. */ +const DROPPED_CHOICE_MESSAGES = [ { - reason: 'the Study workspace is closed', - prepare: async (harness: Harness): Promise => { - await harness.reader.close() - }, - sentence: - 'the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.', + type: 'history', + build: ({ revision, studyId }: DroppedChoiceContext) => ({ + type: 'history', + revision, + studyId, + openRequest: 21, + }), }, { - reason: 'the Study workspace is closing', - prepare: async (harness: Harness): Promise => { - harness.reader['closing'] = true - }, - sentence: - 'the Study workspace is closing. When it has closed, run Codebook: Open Study Workspace, then choose it again.', + type: 'node-history', + build: ({ revision, studyId }: DroppedChoiceContext) => ({ + type: 'node-history', + revision, + studyId, + openRequest: 22, + }), }, { - reason: 'the Learning summary no longer shows a replay', - prepare: async (harness: Harness): Promise => { - await harness.reader.action('return-current') - }, - sentence: - 'the Learning summary no longer shows a replay. Open a replay from Saved studies, then choose again.', + type: 'tab', + build: ({ revision }: DroppedChoiceContext) => ({ + type: 'tab', + revision, + tab: 'live', + }), }, -] as const) { - // Value: protects=a Saved studies choice the Learning summary sends that the reader does not carry out telling the learner why in the words of that reason, in one notification and in one Output › Codebook entry; fails_when=the drop is recorded only in Output, or says the Study workspace is closed when it is closing or the summary left its replay (local open-code-review OCR-L4, Cycle 0056 pt3); why_new=task 17 recorded the drop but worded every reason as a closed workspace and showed the learner nothing; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor - test(`a Saved studies choice from the Learning summary that is dropped because ${dropped.reason} is told in one notification and one Output entry`, async () => { - // Arrange - const harness = await workspaceHarness() - const logged: CodebookDiagnostic[] = [] - harness.reader.diagnostics = { - log: (entry) => logged.push(entry), - show: () => undefined, - } - try { - const history = await openByOpenReplay(harness) - const revision = lastView(harness)!.revision - // Other notices (a Close tells where the keyboard went) are not the drop's. - const droppedNotices = (): string[] => - harness.informationMessages - .map((notice) => notice.message) - .filter((message) => - message.startsWith('Codebook did not carry out your choice'), - ) - await dropped.prepare(harness) + { + type: 'select', + build: ({ revision, itemId }: DroppedChoiceContext) => ({ + type: 'select', + revision, + itemId, + }), + }, +] as const - // Act - harness.receive({ - type: 'node-history', - revision, - studyId: history.studyId, - openRequest: 7, - }) - await waitUntil( - () => droppedNotices().length === 1, - 'the learner is told the choice was not carried out', - READER_RESULT_BOUND_MS, - ) +/** + * Sends one Learning summary message and settles when the reader has handled it, so an assertion that follows reads final counts. + * @param harness - The workspace harness. + * @param message - The message the summary posts. + * @returns Once the reader has handled the message. + * @example await sendFromSummary(harness, { type: 'ready' }) + */ +function sendFromSummary(harness: Harness, message: unknown): Promise { + return readerSeams(harness).receiveFromPanel( + message, + 'context', + harness.summary, + ) +} - // Assert - assert.deepEqual( - { - notices: droppedNotices(), - entries: logged.map((entry) => entry.cause), - }, - { - notices: [ - `Codebook did not carry out your choice: ${dropped.sentence}`, - ], - entries: [ - `The "node-history" choice from the Learning summary was not carried out: ${dropped.sentence}`, - ], - }, - ) - } finally { - harness.reader['closing'] = false - await harness.close() - } - }) +/** + * Reads the notices the reader raised for dropped choices. + * @param harness - The workspace harness. + * @returns The notice texts that start with the dropped-choice prefix. + * @example droppedChoiceNotices(harness) // => ['Codebook did not carry out your choice: the Study workspace is closed. …'] + */ +function droppedChoiceNotices(harness: Harness): string[] { + return harness.informationMessages + .map((notice) => notice.message) + .filter((text) => text.startsWith('Codebook did not carry out your choice')) } -// Value: protects=a Learning summary message that is no choice (the view's ready report) arriving after Close Study Workspace is dropped without a notification or an Output › Codebook entry; fails_when=the drop notice is raised for every dropped message instead of the learner's choices (history, node-history, tab, select), so each stale report tells the learner a choice was not carried out; why_new=the task 17 tests send only a node-history choice, so a notice for any dropped message would pass them; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor -test('a Learning summary report that is no choice is dropped after Close Study Workspace without telling the learner', async () => { - // Arrange - const harness = await workspaceHarness() - const logged: CodebookDiagnostic[] = [] - harness.reader.diagnostics = { - log: (entry) => logged.push(entry), - show: () => undefined, - } - try { - const history = await openByOpenReplay(harness) - const revision = lastView(harness)!.revision - await harness.reader.close() - const noticesBefore = harness.informationMessages.length +// Value: protects=every choice the Learning summary can send (history, node-history, tab, select) telling the learner why it was not carried out, in the words of its reason, in one notification and in one Output › Codebook entry, whether the reader is closed, closing, has left the replay composition or closed while it initialized; fails_when=a message type is dropped without a notice or entry, the reason is worded for another state, or a reason's sentence changes unnoticed (local open-code-review OCR-L4 and /ship pass 2 SM14 to SM25, Cycle 0056 pt3); why_new=task 17 recorded the drop but worded every reason as a closed workspace and showed the learner nothing; seam=workspaceHarness, readerSeams.receiveFromPanel with the Learning summary view, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor +for (const reason of [ + 'closed', + 'closing', + 'left', + 'left for the Live arrangement', + 'closed while initializing', +] as const) + for (const choice of DROPPED_CHOICE_MESSAGES) + test(`a ${choice.type} choice from the Learning summary dropped because the reader is ${reason} is told in one notification and one Output entry`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + const history = await openByOpenReplay(harness) + const view = lastView(harness)! + const message = choice.build({ + revision: view.revision, + studyId: history.studyId, + itemId: view.model.selection.itemId, + }) + const sentence = + DROPPED_CHOICE_SENTENCES[ + reason === 'closed while initializing' + ? 'closed' + : reason === 'left for the Live arrangement' + ? 'left' + : reason + ] + + // Act + if (reason === 'closed while initializing') { + const held = holdNextInitialize(harness) + const received = sendFromSummary(harness, message) + await held.entered + await harness.reader.close() + held.release() + await received + } else { + if (reason === 'closed') await harness.reader.close() + if (reason === 'closing') harness.reader['closing'] = true + if (reason === 'left') await harness.reader.action('return-current') + // The reader stands in the Live arrangement: open, but no longer in the summary composition. + if (reason === 'left for the Live arrangement') { + harness.reader['summaryComposition'] = false + harness.reader['liveComposition'] = true + } + await sendFromSummary(harness, message) + } - // Act - harness.receive({ type: 'ready' }) - // A later choice is told, so the wait below ends only after the ready report was handled. - harness.receive({ - type: 'node-history', - revision, - studyId: history.studyId, - openRequest: 9, + // Assert + assert.deepEqual( + { + notices: droppedChoiceNotices(harness), + entries: logged.map((entry) => entry.cause), + }, + { + notices: [`Codebook did not carry out your choice: ${sentence}`], + entries: [ + `The "${choice.type}" choice from the Learning summary was not carried out: ${sentence}`, + ], + }, + ) + } finally { + harness.reader['closing'] = false + await harness.close() + } }) - await waitUntil( - () => harness.openRequestReplies().length === 1, - 'the Learning summary gets the reply to its dropped choice', - READER_RESULT_BOUND_MS, - ) - // Assert - assert.deepEqual( - { - newNotices: harness.informationMessages - .slice(noticesBefore) - .map((notice) => notice.message), - entries: logged.map((entry) => entry.surface), - }, - { - newNotices: [ - 'Codebook did not carry out your choice: the Study workspace is closed. Run Codebook: Open Study Workspace, then choose it again.', - ], - entries: ['choice:dropped'], - }, - ) - } finally { - await harness.close() - } -}) +// Value: protects=a Learning summary message that is no choice (the view's ready report or its keyboard-focus report) being dropped quietly, with no notification and no Output entry, whether the reader is closed, closing, has left the replay composition or closed while it initialized; fails_when=a stale report tells the learner a choice was not carried out or writes the drop entry (/ship pass 1 SM7 and pass 2 SM26 and SM27, Cycle 0056 pt3); why_new=the task 17 tests send only a node-history choice, so a notice for any dropped message would pass them; seam=workspaceHarness, readerSeams.receiveFromPanel with the Learning summary view, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor +for (const reason of [ + 'closed', + 'closing', + 'left', + 'closed while initializing', +] as const) + for (const report of [ + { name: 'ready report', message: { type: 'ready' } }, + { + name: 'keyboard-focus report', + message: { type: 'pane-focus', focused: true }, + }, + ] as const) + test(`a Learning summary ${report.name} dropped because the reader is ${reason} tells the learner nothing and writes no entry`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + await openByOpenReplay(harness) + + // Act + // The keyboard-focus report is answered before the reader initializes, so only the ready report waits there. + if ( + reason === 'closed while initializing' && + report.message.type === 'ready' + ) { + const held = holdNextInitialize(harness) + const received = sendFromSummary(harness, report.message) + await held.entered + await harness.reader.close() + held.release() + await received + } else { + if (reason === 'closed' || reason === 'closed while initializing') + await harness.reader.close() + if (reason === 'closing') harness.reader['closing'] = true + if (reason === 'left') await harness.reader.action('return-current') + await sendFromSummary(harness, report.message) + } -// Value: protects=an Application group the editor reports active later than the preparation's 150 ms wait: the open is refused with a report of the late activation, never a half-arranged reader; fails_when=the open succeeds with no report or ends in another failure, or the report no longer lists the group change the late activation caused (local open-code-review OCR-L2, Cycle 0056 pt3); why_new=task 17's test pinned only a report that arrives within the bound; seam=workspaceHarness with activateNewGroupLateAfterInsertion(600), the diagnostics log; no_claim=that Cursor ever reports later than the bound (N56 on 5b39b767 and 02acefaa: 13 opens, no refusal) + // Assert + assert.deepEqual( + { + notices: droppedChoiceNotices(harness), + entries: logged.map((entry) => entry.surface), + }, + { notices: [], entries: [] }, + ) + } finally { + harness.reader['closing'] = false + await harness.close() + } + }) + +// Value: protects=an Application group the editor reports active later than the preparation's 150 ms wait: the open is refused with a report of the late activation, never a half-arranged reader; fails_when=the open succeeds with no report or ends in another failure, or the report no longer lists the group change the late activation caused (local open-code-review OCR-L2, Cycle 0056 pt3); why_new=task 17's test pinned only a report that arrives within the bound; seam=workspaceHarness with activateNewGroupLateAfterInsertion(four times the wait), the diagnostics log; no_claim=that Cursor ever reports later than the bound (N56 on 5b39b767 and 02acefaa: 13 opens, no refusal) test('a test replay whose Application group the editor activates after the preparation’s wait is refused with a placement report', async () => { // Arrange const harness = await workspaceHarness() @@ -4458,7 +4540,7 @@ test('a test replay whose Application group the editor activates after the prepa log: (entry) => logged.push(entry), show: () => undefined, } - harness.activateNewGroupLateAfterInsertion(600) + harness.activateNewGroupLateAfterInsertion(LATE_GROUP_ACTIVATION_WAIT_MS * 4) try { const history = await saveTwoStopNodeHistory( harness.service.nodeHistoryStore!, @@ -4531,6 +4613,8 @@ function holdNextReaderOpen(harness: Harness) { * @example heldBackStudyId(harness) // => undefined */ function heldBackStudyId(harness: Harness): string | undefined { + // A renamed field would read `undefined` and let every "nothing is held back" assertion pass without meaning; the guard fails the test instead. + assert.equal('heldBackReplay' in harness.reader, true) return harness.reader['heldBackReplay']?.studyId } @@ -4564,8 +4648,8 @@ test('an Open replay of another test replay that ends after it opened the closed } }) -// Value: protects=one notification per reason for repeated dropped Learning summary choices, and a new notification once the workspace was opened and closed again, while every drop keeps its Output entry; fails_when=each repeated click raises another identical notification, or the notification is never told again after the workspace changed state (local red team RT2, Cycle 0056 pt3); why_new=OCR-L4 told every drop; seam=workspaceHarness, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor -test('repeated Saved studies choices dropped for the same reason raise one notification, and a reopened then closed workspace tells again', async () => { +// Value: protects=one notification while the notice for a dropped Learning summary choice is still open, a new notification once the learner dismissed it, and a new one after the workspace entered the replay composition again, with an Output entry for every drop; fails_when=repeated clicks raise a notice each while the first is open, a dismissed notice is never told again, or entering the composition keeps the suppression (/ship pass 2 C2 and RT2-3, Cycle 0056 pt3); why_new=the first dedupe told once per reason until the workspace opened or closed, so a learner who dismissed the notice and clicked again got nothing; seam=workspaceHarness with information messages the test dismisses itself, readerSeams.receiveFromPanel with the Learning summary view, the diagnostics log; no_claim=the notification's look in the editor +test('repeated Saved studies choices dropped for one reason raise one notification while it is open, and tell again after it was dismissed or the composition was entered', async () => { // Arrange const harness = await workspaceHarness() const logged: CodebookDiagnostic[] = [] @@ -4573,41 +4657,65 @@ test('repeated Saved studies choices dropped for the same reason raise one notif log: (entry) => logged.push(entry), show: () => undefined, } - const droppedNotices = (): string[] => - harness.informationMessages - .map((notice) => notice.message) - .filter((message) => - message.startsWith('Codebook did not carry out your choice'), - ) + // Every information message stays open until the test dismisses it. + const dismissals = new Map void>() + harness.answerInformationMessages( + () => + new Promise((resolve) => { + dismissals.set(harness.informationMessages.length - 1, () => + resolve(undefined), + ) + }), + ) try { const history = await openByOpenReplay(harness) - const choose = (openRequest: number, entries: number) => { - harness.receive({ + const choose = (): Promise => + sendFromSummary(harness, { type: 'node-history', revision: lastView(harness)!.revision, studyId: history.studyId, - openRequest, + openRequest: 31, }) - return waitUntil( - () => logged.length === entries, - 'the dropped choice is recorded', - READER_RESULT_BOUND_MS, - ) + const dismissDroppedNotices = async (): Promise => { + for (const [index, dismiss] of dismissals) + if ( + harness.informationMessages[index]!.message.startsWith( + 'Codebook did not carry out your choice', + ) + ) + dismiss() + // The dismissal settles on microtasks. + await Promise.resolve() + await Promise.resolve() } await harness.reader.close() // Act - await choose(1, 1) - await choose(2, 2) - const afterTwoChoices = droppedNotices().length + await choose() + await choose() + const whileFirstIsOpen = [ + droppedChoiceNotices(harness).length, + logged.length, + ] + await dismissDroppedNotices() + await choose() + const afterDismissal = [droppedChoiceNotices(harness).length, logged.length] await harness.reader.open() await harness.reader.close() - await choose(3, 3) + await choose() // Assert assert.deepEqual( - [afterTwoChoices, droppedNotices().length, logged.length], - [1, 2, 3], + { + whileFirstIsOpen, + afterDismissal, + afterReopen: [droppedChoiceNotices(harness).length, logged.length], + }, + { + whileFirstIsOpen: [1, 2], + afterDismissal: [2, 3], + afterReopen: [3, 4], + }, ) } finally { await harness.close() @@ -4637,6 +4745,13 @@ for (const cell of [ holdsDuringEntry: false, shownAfterwards: 'closed', }, + { + name: 'the same replay whose entry Return to Live supersedes', + entryFor: 'same', + ends: 'superseded by Return to Live', + holdsDuringEntry: false, + shownAfterwards: 'none', + }, { name: 'the same replay whose entry a newer Open replay of another replay supersedes', entryFor: 'same', @@ -4788,65 +4903,9 @@ function holdNextInitialize(harness: Harness) { return { entered: hasEntered, release } } -// Value: protects=a Learning summary message dropped because the workspace closed while the reader was initializing: a choice tells the learner once and records one Output entry and releases the pane, a report that is no choice is dropped quietly; fails_when=the second owner check drops silently or tells for a message that is no choice (coverage review SM6 and SM7, Cycle 0056 pt3); why_new=the drop after initialize() was untested; seam=workspaceHarness, the reader's initialize held, the Learning summary view's listener, the harness's information messages, the diagnostics log; no_claim=the notification's look -for (const message of [ - { name: 'a Saved studies choice', isChoice: true }, - { name: 'a report that is no choice', isChoice: false }, -] as const) - test(`${message.name} sent from the Learning summary before the workspace closed while the reader initialized is ${message.isChoice ? 'told once and recorded' : 'dropped quietly'}`, async () => { - // Arrange - const harness = await workspaceHarness() - const logged: CodebookDiagnostic[] = [] - harness.reader.diagnostics = { - log: (entry) => logged.push(entry), - show: () => undefined, - } - try { - const history = await openByOpenReplay(harness) - const noticesBefore = harness.informationMessages.length - const held = holdNextInitialize(harness) - const received = readerSeams(harness).receiveFromPanel( - message.isChoice - ? { - type: 'node-history', - revision: lastView(harness)!.revision, - studyId: history.studyId, - openRequest: 11, - } - : { type: 'ready' }, - 'context', - harness.summary, - ) - await held.entered - - // Act: the workspace closes while the message waits, then the reader goes on. - await harness.reader.close() - held.release() - await received - - // Assert - assert.deepEqual( - { - newNotices: harness.informationMessages - .slice(noticesBefore) - .map((notice) => notice.message) - .filter((text) => - text.startsWith('Codebook did not carry out your choice'), - ).length, - entries: logged.map((entry) => entry.surface), - }, - message.isChoice - ? { newNotices: 1, entries: ['choice:dropped'] } - : { newNotices: 0, entries: [] }, - ) - } finally { - await harness.close() - } - }) - -// Value: protects=a message of a Study pane that a handoff retired being dropped without a notification or a choice:dropped entry, whether the handoff came before the message or while the reader initialized; fails_when=a retired Study pane's choice tells the learner or writes the Learning summary's drop entry (coverage review SM8 and SM9, Cycle 0056 pt3); why_new=only the Learning summary's drop was tested; seam=readerSeams.receiveFromPanel with a pane that is not the reader's Study pane, the reader's initialize held, the information messages, the diagnostics log; no_claim=the replacement pane's behaviour +// Value: protects=a message of a Study pane that a handoff retired being dropped without a notification or a choice:dropped entry and without changing the reader, whether the handoff came before the message or while the reader initialized; fails_when=a retired Study pane's choice tells the learner, writes the Learning summary's drop entry, or changes the shown tab (coverage review SM8 and SM9 and /ship pass 2 T6, Cycle 0056 pt3); why_new=only the Learning summary's drop was tested; seam=readerSeams.receiveFromPanel with a pane that is not the reader's Study pane, the reader's initialize held, a tab choice from the live Study pane as the sentinel, the information messages, the diagnostics log; no_claim=the replacement pane's behaviour for (const handoff of ['before', 'while'] as const) - test(`a choice from a Study pane that a handoff retired ${handoff === 'before' ? 'before' : 'while'} the reader initialized is dropped without a notification or an entry`, async () => { + test(`a choice from a Study pane that a handoff retired ${handoff} the reader initialized is dropped without a notification, an entry or a change`, async () => { // Arrange const harness = await workspaceHarness() const logged: CodebookDiagnostic[] = [] @@ -4857,11 +4916,14 @@ for (const handoff of ['before', 'while'] as const) try { await harness.reader.open() const studyPane = harness.reader['contextPanel'] + const tabBefore = harness.reader.inspect().view?.contentTab + // A tab the reader is not showing, so a carried-out choice would change what it shows. + const otherTab = tabBefore === 'live' ? 'overview' : 'live' const noticesBefore = harness.informationMessages.length const message = { type: 'tab', revision: lastView(harness)!.revision, - tab: 'live', + tab: otherTab, } // Act @@ -4881,16 +4943,190 @@ for (const handoff of ['before', 'while'] as const) await received harness.reader['contextPanel'] = studyPane } + const tabAfterDrop = harness.reader.inspect().view?.contentTab + // The live Study pane's own choice is carried out: the reader was handling messages all along. + await readerSeams(harness).receiveFromPanel( + { ...message, revision: lastView(harness)!.revision }, + 'context', + studyPane, + ) // Assert assert.deepEqual( { newNotices: harness.informationMessages.length - noticesBefore, entries: logged.map((entry) => entry.surface), + tabAfterDrop, + tabAfterSentinel: harness.reader.inspect().view?.contentTab, + }, + { + newNotices: 0, + entries: [], + tabAfterDrop: tabBefore, + tabAfterSentinel: otherTab, }, - { newNotices: 0, entries: [] }, ) } finally { await harness.close() } }) + +// Value: protects=the replay a closed reader showed staying out of the Learning summary and the bottom panel while a newer entry loads, when the entry that opened the reader ends superseded first; fails_when=the first entry's end releases and republishes the held-back replay while the newer entry still loads, so both views draw the closed reader's replay for a moment (/ship pass 2 RT2-1, Cycle 0056 pt3); why_new=the hold was released by the entry that set it, though the newer entry takes the standing-composition branch and sets none; seam=the reader's openNodeHistory held per entry, the summary's and bottom panel's postMessage; no_claim=the native frames +test('a newer Open replay that supersedes the entry which opened the closed reader keeps the closed reader’s replay out of both views while it loads', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const firstReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const newerReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const openNodeHistory = harness.reader['openNodeHistory'].bind( + harness.reader, + ) + const gates: { entered: Promise; release: () => void }[] = [] + let calls = 0 + harness.reader['openNodeHistory'] = async (...openArguments) => { + let release = (): void => {} + let entered = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const hasEntered = new Promise((resolve) => { + entered = resolve + }) + gates.push({ entered: hasEntered, release }) + entered() + await gate + // The first entry's open ends superseded, as a newer entry makes it; the newer entry's runs. + return calls++ === 0 + ? { status: 'superseded' as const } + : openNodeHistory(...openArguments) + } + const summaryDrawn = recordDrawnTestReplays(harness.summary), + bottomPanelDrawn = recordDrawnTestReplays(harness.playbackPanel) + const first = harness.reader.openLearningReplay(firstReplay.studyId) + await waitUntil( + () => gates.length === 1, + 'the first entry reaches its open', + READER_RESULT_BOUND_MS, + ) + const newer = harness.reader.openLearningReplay(newerReplay.studyId) + await waitUntil( + () => gates.length === 2, + 'the newer entry reaches its open', + READER_RESULT_BOUND_MS, + ) + + // Act: the first entry ends while the newer one still loads, then the newer one finishes. + gates[0]!.release() + await first + const drawnWhileNewerLoads = [...summaryDrawn, ...bottomPanelDrawn] + gates[1]!.release() + await newer + await landsOn(harness, 2, 'the newer replay lands on its first stop') + + // Assert + assert.deepEqual( + { + closedReplayDrawnWhileNewerLoads: drawnWhileNewerLoads.includes( + closedReplay.studyId, + ), + summaryLast: summaryDrawn.at(-1), + }, + { + closedReplayDrawnWhileNewerLoads: false, + summaryLast: newerReplay.studyId, + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=Close while an entry for another replay holds the closed reader's replay back leaving no hold, so the next Open Study Workspace draws that replay; fails_when=the hold outlives the Close and the reopened reader's views never draw the replay (/ship pass 2 RT2-1, Cycle 0056 pt3); why_new=the hold was cleared only by the entry that set it; seam=the reader's open held at its start, the summary's postMessage; no_claim=the native frames +test('a Close while an entry for another replay holds the closed reader’s replay back leaves the next Open Study Workspace drawing that replay', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const otherReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const held = holdNextReaderOpen(harness) + const entry = harness.reader.openLearningReplay(otherReplay.studyId) + await held.entered + const heldDuringEntry = heldBackStudyId(harness) + + // Act + await harness.reader.close() + const heldAfterClose = heldBackStudyId(harness) + held.release() + await entry + held.restore() + const drawn = recordDrawnTestReplays(harness.summary) + await harness.reader.open() + await waitUntil( + () => drawn.at(-1) === closedReplay.studyId, + 'the reopened reader draws the closed reader’s replay', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + [heldDuringEntry, heldAfterClose], + [closedReplay.studyId, undefined], + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a view update that throws at the release of the held-back replay being reported and never replacing the entry's own result; fails_when=the republish at the release throws out of openLearningReplay, so the learner's Open replay fails for a drawing error (/ship pass 2 RT2-2, Cycle 0056 pt3); why_new=the release republished unguarded; seam=the reader's openNodeHistory ending superseded and its publish throwing once; no_claim=why a publish would throw +test('a publish that throws when the held-back replay is released is reported and does not fail the Open replay', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const chosenReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + await harness.reader.close() + const publish = harness.reader['publish'].bind(harness.reader) + let armed = false + harness.reader['publish'] = (): void => { + if (armed && harness.reader['heldBackReplay'] === undefined) { + armed = false + throw new Error('The view refused the update.') + } + publish() + } + harness.reader['openNodeHistory'] = async () => { + armed = true + return { status: 'superseded' as const } + } + + // Act + const result = await harness.reader + .openLearningReplay(chosenReplay.studyId) + .then( + () => 'resolved', + (error: unknown) => String(error), + ) + + // Assert + assert.deepEqual( + [result, readerSeams(harness).errorLines().join(' | ')], + ['resolved', 'Error: The view refused the update.'], + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/utils/wikiUpdateWindow.ts b/extension/src/tests/utils/wikiUpdateWindow.ts index 2042239c..b64b02a8 100644 --- a/extension/src/tests/utils/wikiUpdateWindow.ts +++ b/extension/src/tests/utils/wikiUpdateWindow.ts @@ -556,9 +556,10 @@ export async function openWikiUpdateWindow(options: { commands.set(name, action) return { dispose(): void {} } }, - // The commands of the chat a supported editor has, without those the test takes away. + // The commands of the chat a supported editor has, and its trust command, without those the test takes away. getCommands: async () => [ + 'workbench.trust.manage', 'workbench.action.chat.open', 'workbench.action.chat.newChat', 'workbench.action.chat.cancel', diff --git a/extension/src/utils/registerLlmWikiCommands.ts b/extension/src/utils/registerLlmWikiCommands.ts index c0a46b66..7621fc3b 100644 --- a/extension/src/utils/registerLlmWikiCommands.ts +++ b/extension/src/utils/registerLlmWikiCommands.ts @@ -80,7 +80,7 @@ async function showRowDetail( } /** - * Runs the editor's Manage Workspace Trust command; says where to find it when the editor lacks the command, and that the command could not open when it rejects for another cause. + * Runs the editor's Manage Workspace Trust command; says where to find it when the editor's command list lacks it, and that it could not open when the editor has the command and it rejects. * * Trigger: the Manage Workspace Trust action of the view's trust row ({@link registerLlmWikiCommands}). * @param options - The window's closures. @@ -92,15 +92,15 @@ async function manageTrust(options: LlmWikiCommandOptions): Promise { await options.editor.commands.executeCommand(MANAGE_WORKSPACE_TRUST_COMMAND) } catch (cause) { options.recordFailure('Manage Workspace Trust could not run', cause) - // The editor says "command '' not found" when it lacks the command; only then the learner learns where else to trust the folder. Any other rejection is a command that exists and failed. - const lacksCommand = thrownMessage(cause).includes( - `command '${MANAGE_WORKSPACE_TRUST_COMMAND}' not found`, - ) + // Whether the editor has the command decides the notice, never the wording of its error: absent, the learner is pointed to the Command Palette; present, the command failed. + const editorCommands = await Promise.resolve( + options.editor.commands.getCommands(true), + ).catch((): string[] => []) void options.showNotice( 'information', - lacksCommand - ? LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE - : LLM_WIKI_MANAGE_TRUST_FAILED_NOTICE, + editorCommands.includes(MANAGE_WORKSPACE_TRUST_COMMAND) + ? LLM_WIKI_MANAGE_TRUST_FAILED_NOTICE + : LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE, ) } } From 25ff222f7a9006dfd1c821e6760ef09474b1b17c Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 13:39:36 +0900 Subject: [PATCH 22/31] fix: Cycle 0056 pt3: pre-landing review fixes, pass 3 (Open keeps Close's state, hold bound to the retained selection, every drop notice, command list failures, unattributed refusals, all workspace roots) --- extension/README.md | 4 +- extension/src/constants.ts | 12 +- extension/src/extension.ts | 4 +- extension/src/studyWorkspace.ts | 70 ++-- extension/src/tests/causeRootsFor.test.ts | 27 ++ extension/src/tests/llmWikiView.test.ts | 42 +++ .../src/tests/placementRefusalReport.test.ts | 4 +- .../src/tests/studyWorkspaceEntry.test.ts | 56 ++++ ...tudyWorkspaceTestReplayComposition.test.ts | 313 ++++++++++++------ extension/src/tests/utils/wikiUpdateWindow.ts | 31 +- extension/src/utils/causeRootsFor.ts | 6 +- extension/src/utils/placementRefusalReport.ts | 3 +- .../src/utils/registerLlmWikiCommands.ts | 10 +- 13 files changed, 417 insertions(+), 165 deletions(-) create mode 100644 extension/src/tests/causeRootsFor.test.ts diff --git a/extension/README.md b/extension/README.md index b6abe258..53ca06d1 100644 --- a/extension/README.md +++ b/extension/README.md @@ -75,7 +75,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the **Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. -A choice you make in the Learning summary that Codebook cannot carry out is not lost silently: the notice names the reason and what to do next. The notice starts `Codebook did not carry out your choice:` and ends with one of three reasons. `the Study workspace is closed` asks you to run **Codebook: Open Study Workspace**, then choose it again. `the Study workspace is closing` asks you to run it when it has closed, then choose it again. `the Learning summary no longer shows that replay` (the workspace is open but shows the Live arrangement or the Study pane) asks you to open a replay from Saved studies, then choose it again. While a notice for a reason is still open, a repeated choice raises no second one; once you dismiss it, or when the workspace opens, closes or enters the learning-replay composition, the next dropped choice tells again. **Output › Codebook** records one `choice:dropped` entry with the reason for every dropped choice, naming the study. +A choice you make in the Learning summary that Codebook cannot carry out is not lost silently: the notice names the reason and what to do next. The notice starts `Codebook did not carry out your choice:` and ends with one of three reasons. `the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.` `the Study workspace is closing. When it has closed, run “Codebook: Open Study Workspace”, then make your choice again.` `the Learning summary is not showing a replay. Open a replay from Saved studies, then make your choice again.` (the workspace is open but shows the Live arrangement or the Study pane). Every dropped choice raises its notice again; the editor shows one notice for identical texts. **Output › Codebook** records one `choice:dropped` entry for every dropped choice, naming the study when the choice carries one. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. @@ -246,7 +246,7 @@ For tools: `GET /api/update-status` answers the current state as JSON (` The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site of the opened project stands and the next step; it sits below the **Repository Wiki** view, which lists repository articles, not the generated site. Its rows come and go with the facts they show, in a fixed order, and the tooltip of a row has its full text: -- With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. If the editor has no Manage Workspace Trust command the view points to "Workspaces: Manage Workspace Trust" in the Command Palette; if the command fails, the view says it could not open, names that command, and the cause is in **Output › Codebook**. +- With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. If the editor has no Manage Workspace Trust command the view points to **Workspaces: Manage Workspace Trust** in the Command Palette (also when the editor's command list cannot be read); if the command fails, the view says it could not open, names that command, and the cause is in **Output › Codebook**. - The view follows its Wiki folder on disk: creating the folder, or deleting or moving it or any folder above it inside your project (for example `rm -rf .codebook`), refreshes the rows within a second, and a deleted Wiki brings back the welcome without **Refresh**. A Wiki folder outside the project is watched from its own parent folder only, so deleting that parent shows after **Refresh** or the next time the view is shown (Codebook never watches your home folder or a drive root). - In a folder with no Wiki and no run the view shows a welcome with a **Generate LLM Wiki** button, what runs it in your editor (Copilot Chat in VS Code, `cursor-agent` in Cursor) and the output folder with a **Change location** link (`The Wiki is written to .codebook/wiki unless the setting codebook.wikiOutputDirectory names another folder.`). A click on the **Output** row opens the same setting. - **` Wiki`** is the published Wiki. A click opens it in the browser. Its description is the freshness label of the reader page (`Wiki source current`, `Source changed · Update available`, `Wiki freshness unknown: …`, or `Checking freshness…` while the check runs), and its tooltip names the generation: `Generated from on `, `Generated on (no Git revision)` in a folder without Git, or `Generated from ` when the date cannot be read; when the Wiki records no Git revision, the tooltip also says in full that added files are not checked, which the description may cut off in a narrow sidebar. **Update LLM Wiki** is its action while no run shows. The row stays while a run works, so you can read the Wiki during an update. diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 9fbc165b..05713679 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1066,7 +1066,7 @@ export const LLM_WIKI_ROW_ACTIONS = { }, } as const -/** What the LLM Wiki view says when the editor rejects its Manage Workspace Trust command ({@link MANAGE_WORKSPACE_TRUST_COMMAND}, run by {@link registerLlmWikiCommands}). */ +/** What the LLM Wiki view says when the editor's command list lacks (or cannot list) its Manage Workspace Trust command ({@link MANAGE_WORKSPACE_TRUST_COMMAND}, run by {@link registerLlmWikiCommands}). */ export const LLM_WIKI_MANAGE_TRUST_FALLBACK_NOTICE = 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.' @@ -1850,6 +1850,10 @@ export const REFUSAL_REPORT_GROUP_TABS = 8 /** The longest tab label a placement refusal report shows, in characters. */ export const REFUSAL_REPORT_LABEL_CHARS = 48 +/** What a placement refusal report prints as its failed checks when the guard refused with none of the compared parts differing. */ +export const REFUSAL_REPORT_UNATTRIBUTED_CHECKS = + 'unattributed (none of the compared parts differ)' + /** The most editor events a {@link PlacementEventLog} keeps and a placement refusal report lists, in events. */ export const REFUSAL_REPORT_EVENTS = 24 @@ -1861,15 +1865,15 @@ export const LATE_GROUP_ACTIVATION_WAIT_MS = 150 /** Why the reader did not carry out a choice the Learning summary sent while the Study workspace was closed; completes "The … choice from the Learning summary was not carried out: " in Output › Codebook and "Codebook did not carry out your choice: " in the notice the learner sees. */ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSED = - 'the Study workspace is closed. To continue, run Codebook: Open Study Workspace, then choose it again.' + 'the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.' /** The reason when the choice arrived while the Study workspace was closing. See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ export const SUMMARY_CHOICE_DROPPED_REASON_CLOSING = - 'the Study workspace is closing. When it has closed, run Codebook: Open Study Workspace, then choose it again.' + 'the Study workspace is closing. When it has closed, run “Codebook: Open Study Workspace”, then make your choice again.' /** The reason when the reader had left the summary composition (the Study pane stands again). See {@link SUMMARY_CHOICE_DROPPED_REASON_CLOSED}. */ export const SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT = - 'the Learning summary no longer shows that replay. Open a replay from Saved studies, then choose it again.' + 'the Learning summary is not showing a replay. Open a replay from Saved studies, then make your choice again.' /** What the learner's notice for a dropped Learning summary choice starts with, before the reason. */ export const SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX = diff --git a/extension/src/extension.ts b/extension/src/extension.ts index a01f5802..1efbd893 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -3194,7 +3194,9 @@ export function activate( `${message}: ${thrownMessage(cause)}`, causeRootsFor( [context.globalStorageUri.fsPath], - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + (vscode.workspace.workspaceFolders ?? []).map( + (folder) => folder.uri.fsPath, + ), ), ), ) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 9d85b91b..e7223149 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -994,13 +994,11 @@ export class StudyWorkspace { /** `true` while {@link openNodeHistoryFromHost} enters the summary composition for a test replay that has not begun opening ({@link testReplayHoldsComposition}). */ private hostOpeningTestReplay = false /** - * The replay a closed reader keeps selected for its next Open Study Workspace while an entry for another replay opens; the Learning summary and the bottom panel receive no message that carries it ({@link publish}). It is released when the last entry in progress ends ({@link openNodeHistoryFromHost}, {@link nodeHistoryEntriesInProgress}) or at {@link close}, and it holds nothing once the chosen replay replaces the selection. + * The replay a closed reader keeps selected for its next Open Study Workspace while an entry for another replay opens; the Learning summary and the bottom panel receive no message that carries it ({@link publish}). It is released when the last entry in progress ends ({@link openNodeHistoryFromHost}, {@link nodeHistoryEntriesInProgress}) or at {@link close}, and the hold is bound to the retained selection (its open token), so it holds nothing once any history, the same replay included, is adopted. */ - private heldBackReplay: { studyId: string } | undefined + private heldBackReplay: { openToken: number | undefined } | undefined /** How many test replay entries ({@link openNodeHistoryFromHost}) have not ended yet, from the turn they wait for to their open's end; the held-back replay is released when the last one ends. */ private nodeHistoryEntriesInProgress = 0 - /** The last notice told for a dropped Learning summary choice, with whether the learner has not dismissed it yet: a repeat of its reason raises no second notice while it is open ({@link noteDroppedSummaryChoice}); forgotten at {@link open}, {@link close} and when the summary composition is entered. */ - private droppedChoiceNotice: { reason: string; isOpen: boolean } | undefined /** The editor's recent group and active text editor changes, for {@link refusePlacement}'s report (F382). */ private readonly placementEvents = new PlacementEventLog() /** The native effects this host itself issued (reveals, disposals, editor commands, shown documents), for {@link refusePlacement}'s report (N55-F4). */ @@ -8168,8 +8166,6 @@ export class StudyWorkspace { forAgent = false, ): Promise { if (origin !== 'presentation') this.assertPresentationIdle() - // A reader that opens again tells a dropped Learning summary choice anew (the notice is once per reason and state). - this.droppedChoiceNotice = undefined signal?.throwIfAborted() const lifetime = this.lifetime this.assertLifetime(lifetime) @@ -8289,8 +8285,6 @@ export class StudyWorkspace { this.presentationConfirmed = false this.liveComposition = liveComposition this.summaryComposition = summaryComposition - // Entering the summary composition tells a dropped choice anew. - if (summaryComposition) this.droppedChoiceNotice = undefined } if (this.liveComposition || this.summaryComposition) { // Live uses the host chat; disposing only our panel never closes another editor's tabs. @@ -8302,6 +8296,9 @@ export class StudyWorkspace { if (disposedStudyPane && this.hasRetiredStudyPaneTab()) await this.retiredStudyPaneClosed(signal) } + // A Close or a newer Open that completed during the wait owns the reader's state: this Open writes none of it (Cycle 0056 pt3, CX1). + this.assertLifetime(lifetime) + signal?.throwIfAborted() this.opened = true this.closing = false // Explicit Study after actual Return uses the same retained layout proof as a Wiki evidence link. @@ -9593,7 +9590,6 @@ export class StudyWorkspace { // Read before anything is closed or shown: the tab outside the text editors and the Study panes that has the keyboard now (Cursor's chat), which gets it back when this close shows a side bar. const giveKeyboardBackToChat = this.retainForeignTabFocus(undefined) this.closing = true - this.droppedChoiceNotice = undefined this.heldBackReplay = undefined this.revision++ this.lifetime++ @@ -11947,10 +11943,12 @@ export class StudyWorkspace { // The closed reader's replay held back for the entries is drawn again, by the next Open Study Workspace if no entry replaced it; a newer entry still in progress keeps the hold, so the views never draw the retained replay while it loads (Cycle 0056 task 32). this.nodeHistoryEntriesInProgress-- if (this.nodeHistoryEntriesInProgress === 0 && this.heldBackReplay) { + // Republish only when the held selection is still the selected replay; an entry that adopted a history posted it already. + const isStillHeld = this.isHeldBackSelection() this.heldBackReplay = undefined - this.lastPublished = undefined - // An entry that opened the reader and then failed or was superseded leaves the views on what the hold showed; they draw the retained replay again now. A failing publish is reported and never replaces the entry's own result. - if (this.opened && !this.closing && !this.disposed) { + if (isStillHeld && this.opened && !this.closing && !this.disposed) { + this.lastPublished = undefined + // A failing publish is reported and never replaces the entry's own result. try { this.publish() } catch (error) { @@ -12110,9 +12108,7 @@ export class StudyWorkspace { this.selectedNodeHistory && this.selectedNodeHistory.header.studyId !== studyId ) - this.heldBackReplay = { - studyId: this.selectedNodeHistory.header.studyId, - } + this.heldBackReplay = { openToken: this.nodeHistoryOpenToken } // The open's waits for the work window end too when a newer entry, a Return or a Close supersedes this one (Cycle 0052 task 44). await this.open(overtaken) } finally { @@ -13939,6 +13935,19 @@ export class StudyWorkspace { return this.recordingReloadStudyId !== undefined ? 'browser' : undefined } + /** + * Says whether the selected replay is the closed reader's replay held back for another replay's entry; read by {@link postToReplayViews} and by the release in {@link openNodeHistoryFromHost}. + * @returns `true` while a hold stands and the selection is still the one it was taken on (the same open token); `false` once any history was adopted, the same replay again included. + * @example if (this.isHeldBackSelection()) return + */ + private isHeldBackSelection(): boolean { + return ( + this.heldBackReplay !== undefined && + this.selectedNodeHistory !== undefined && + this.nodeHistoryOpenToken === this.heldBackReplay.openToken + ) + } + /** * Posts a published message to the Learning summary and the bottom panel; called by {@link publish}. * The Learning summary draws only a shown replay: a publish while the reader leaves one would draw the Live Study page there (Cycle 0052 task 2, N51-V2). A replay held back for another replay's entry ({@link heldBackReplay}) is drawn by neither view (Cycle 0056 task 32). @@ -13946,9 +13955,7 @@ export class StudyWorkspace { * @example this.postToReplayViews(compact) */ private postToReplayViews(compact: StudyViewMessage): void { - const isHeldBack = - this.heldBackReplay !== undefined && - this.selectedNodeHistory?.header.studyId === this.heldBackReplay.studyId + const isHeldBack = this.isHeldBackSelection() if (this.summaryComposition && compact.replayFamily && !isHeldBack) void this.summaryView?.webview.postMessage(compact) if (this.moments) @@ -22259,6 +22266,7 @@ export class StudyWorkspace { * @param identity - The allocated group's identity ({@link editorGroupIdentities}). * @param signal - The preparation's cancellation. * @returns Once the report arrived, the bound passed or the signal aborted; never rejects. + * It does not end early when the allocated group is already the active group: the editor makes the group active in its model before it reports the activation, so the report is still on its way (N55-F4 H2-b, pinned by the late-activation test). * @example await this.waitForLateGroupActivation(identity, signal) */ private async waitForLateGroupActivation( @@ -23605,27 +23613,11 @@ export class StudyWorkspace { : {}), cause: `The "${parsed.data.type}" choice from the Learning summary was not carried out: ${reason}`, }) - // The learner sees the drop too, once while the notice for this reason stays open; once it is dismissed the next drop tells again. Every drop above still has its Output entry. - if ( - this.droppedChoiceNotice?.reason === reason && - this.droppedChoiceNotice.isOpen - ) - return - const told = { reason, isOpen: true } - this.droppedChoiceNotice = told - void Promise.resolve( - showPlainNotice( - vscode.window, - 'information', - `${SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX}${reason}`, - ), - ).then( - () => { - told.isOpen = false - }, - () => { - told.isOpen = false - }, + // The learner sees every drop; the editor replaces an identical visible notification instead of stacking it. Every drop also has its Output entry above. + void showPlainNotice( + vscode.window, + 'information', + `${SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX}${reason}`, ) } diff --git a/extension/src/tests/causeRootsFor.test.ts b/extension/src/tests/causeRootsFor.test.ts new file mode 100644 index 00000000..5d345a55 --- /dev/null +++ b/extension/src/tests/causeRootsFor.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { test } from 'node:test' + +import { causeRootsFor } from '../utils/causeRootsFor' +import { sanitizeCause } from '../utils/sanitizeCause' + +// Value: protects=a path under any opened workspace folder, not only the first, reading in a cause; fails_when=causeRootsFor names only one folder, so a failure under the second folder of a multi-root window shows its real path in Output (/ship pass 3 MR, Cycle 0056 pt3); why_new=the Wiki failure recorder passed only workspaceFolders[0]; seam=two real temporary folders; no_claim=the editor's folder order +test('a cause names no path under any opened workspace folder', async () => { + // Arrange + const first = await mkdtemp(join(tmpdir(), 'codebook-root-a-')) + const second = await mkdtemp(join(tmpdir(), 'codebook-root-b-')) + + // Act + const cause = sanitizeCause( + `Failed at ${join(second, 'src', 'a.ts')} and ${join(first, 'b.ts')}`, + causeRootsFor([], [first, second]), + ) + + // Assert + assert.equal( + cause, + `Failed at ${sep}src${sep}a.ts and ${sep}b.ts`, + ) +}) diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts index b01012e6..cd2d1e3e 100644 --- a/extension/src/tests/llmWikiView.test.ts +++ b/extension/src/tests/llmWikiView.test.ts @@ -526,6 +526,41 @@ test('Manage Workspace Trust says it could not open, and records the cause, when ) }) +// Value: protects=the learner still being pointed to the Command Palette, and the cause recorded, when the editor's command list cannot be read (it rejects or throws at once), because the command's presence is then unknown and the palette route always works; fails_when=a failing command list makes the trust action reject with no notice, or shows the could-not-open notice for a command that may be missing (/ship pass 3 G-SYNC, Cycle 0056 pt3); why_new=pass 2 asked the list through Promise.resolve, which misses a synchronous throw, and the failing list was untested; seam=the stubbed editor's getCommands failing after workbench.trust.manage rejected; no_claim=the wording of a native editor's own error +for (const listFailure of ['rejects', 'throws at once'] as const) + test(`Manage Workspace Trust points to the Command Palette and records the cause when the editor’s command is rejected and its command list ${listFailure}`, async (context) => { + // Arrange + const { window } = await openPublishedWiki(context, { + trusted: false, + commandListFails: listFailure === 'rejects' ? 'reject' : 'throw', + editorCommand: (name) => { + if (name === 'workbench.trust.manage') + throw new Error('The trust editor crashed') + }, + }) + + // Act + await window.runCommand('codebook.llmWiki.manageTrust') + + // Assert + assert.deepEqual( + [ + window.notices, + window.outputLines.some( + (line) => + line.startsWith('error: ') && + line.includes('The trust editor crashed'), + ), + ], + [ + [ + 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.', + ], + true, + ], + ) + }) + // Value: protects=the Output entry of a failed Manage Workspace Trust never carrying the home directory or a control character that the editor's error held; fails_when=the cause reaches Output as the editor worded it (/ship pass 2 S1, Cycle 0056 pt3); why_new=the view's failure recorder wrote the raw error text; seam=the stubbed editor's trust command throwing a message with the home path and a bell character; no_claim=the wording of a native editor's own error test('a failed Manage Workspace Trust records its cause in Output without the home directory or control characters', async (context) => { // Arrange @@ -1320,6 +1355,13 @@ test('the message changes only when the tree reads the new rows, not when the ch 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.' const stoppedMessage = 'Codebook stopped watching the chat, which may still publish this Wiki. To stop it, stop the request in the chat.' + // The run row shows before the message does; the stop is pressed only once the view shows the watching message and has settled, so the log starts from that state. + await waitUntil( + () => project.llmWiki.message() === watchingMessage, + 'the view shows the watching message', + 6_000, + ) + await project.llmWiki.whenIdle() // Act await project.runCommand('codebook.llmWiki.stopWatching') diff --git a/extension/src/tests/placementRefusalReport.test.ts b/extension/src/tests/placementRefusalReport.test.ts index 1fd69d38..d465dfaa 100644 --- a/extension/src/tests/placementRefusalReport.test.ts +++ b/extension/src/tests/placementRefusalReport.test.ts @@ -142,7 +142,7 @@ test('names the call site, the failed checks, the front tabs, the focused tab an ) }) -// Value: protects=a guard that retains no focus tab and has no effects still prints a report without the new lines' guesses: no front tabs, no focus lines and an empty effects list that says none; fails_when=the report invents a front tab or leaves the effects heading without a line; why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=the guards that name them +// Value: protects=a guard that refused with none of the compared parts differing saying so (unattributed) instead of a bare none, and still printing a report without the new lines' guesses: no front tabs, no focus lines and an empty effects list that says none; fails_when=the report invents a front tab, leaves the effects heading without a line, or prints `Failed checks: none` for a refusal no comparison explains (/ship pass 3 FC, Cycle 0056 pt3); why_new=Cycle 0056 task 17 stage 1; seam=none; no_claim=the guards that name them test('says none when no effect of Codebook’s own was issued since arming and shows no focus lines without them', () => { // Arrange const group = { viewColumn: 1, isActive: true, tabs: [] } @@ -164,7 +164,7 @@ test('says none when no effect of Codebook’s own was issued since arming and s report, [ 'Guard: placement', - 'Failed checks: none', + 'Failed checks: unattributed (none of the compared parts differ)', 'Armed 5 ms before the refusal', 'Groups before: 1* []', 'Groups after: 1* []', diff --git a/extension/src/tests/studyWorkspaceEntry.test.ts b/extension/src/tests/studyWorkspaceEntry.test.ts index 59a697ba..9e59350a 100644 --- a/extension/src/tests/studyWorkspaceEntry.test.ts +++ b/extension/src/tests/studyWorkspaceEntry.test.ts @@ -11,6 +11,11 @@ import { saveOneStopNodeHistory, workspaceHarness, } from './studyWorkspaceHarness' +import { + advanceClockUntilSettled, + enableFakeClock, + resetFakeClock, +} from './utils/fakeClock' for (const replaceWrappers of [false, true]) test(`MCP-first App preparation reserves its own locked group and preserves both learner groups${replaceWrappers ? ' across native wrapper replacement' : ''}`, async () => { @@ -3249,3 +3254,54 @@ for (const row of [ await harness.close() } }) + +// Value: protects=a Close Study Workspace that completes while an Open waits for a retired Study tab's close staying closed: the stale Open writes none of its state over the Close; fails_when=the Open sets opened and clears closing after the wait, so the reader reports open with no panes and its next Open skips the first-open work (Codex adversarial and structured CX1, Cycle 0056 pt3); why_new=open() wrote opened and closing before it rechecked its lifetime; seam=holdDisposedPanelTabCloses for the Study context tab, the live run that makes the Open dispose the Study pane, a Close completed meanwhile; no_claim=the native tab model +test('a Close that completes while an Open waits for the retired Study tab leaves the reader closed', async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + harness.useNativeLayout({ orientation: 0, groups: [{ size: 980 }] }) + await harness.reader.open() + harness.closeDisposedPanelTabsLater() + harness.holdDisposedPanelTabCloses() + harness.service.start({ + instanceId: harness.service.instanceId, + profile: 'repository-browser', + configuration: harness.configuration, + key: randomUUID(), + }) + await [...harness.service.records.values()][0]!.preparation + // No AbortSignal: a Return to Live and a command Open wait this way. + const opening = harness.reader.open().then( + () => 'opened', + (error: unknown) => String(error), + ) + await waitUntil( + () => harness.reader['hasRetiredStudyPaneTab'](), + 'the Open disposes the Study pane and waits for its tab to close', + 5_000, + ) + + // Act: the Close completes while the Open waits, then the editor closes the tab. + // The Close drains the editor for the native bound; the faked clock passes it. + const closing = harness.reader.close() + enableFakeClock(context) + await advanceClockUntilSettled(context, closing, 10_000) + await closing + resetFakeClock(context) + harness.closePendingDisposedPanelTabs() + await opening + + // Assert + assert.deepEqual( + [ + harness.reader.inspect().opened, + harness.reader.inspect().view?.readerClosed, + ], + [false, true], + ) + } finally { + resetFakeClock(context) + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 2d8480d1..a0f2bc31 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -263,7 +263,7 @@ test('a Saved studies choice sent from the Learning summary after the Study work surface: 'choice:dropped', studyId: history.studyId, cause: - 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. To continue, run Codebook: Open Study Workspace, then choose it again.', + 'The "node-history" choice from the Learning summary was not carried out: the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.', }, ], ) @@ -4321,10 +4321,10 @@ test('a refused Open replay of another test replay after Close leaves the closed /** What the learner is told, and the Output entry says, for each reason a Learning summary choice is dropped. */ const DROPPED_CHOICE_SENTENCES = { closed: - 'the Study workspace is closed. To continue, run Codebook: Open Study Workspace, then choose it again.', + 'the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.', closing: - 'the Study workspace is closing. When it has closed, run Codebook: Open Study Workspace, then choose it again.', - left: 'the Learning summary no longer shows that replay. Open a replay from Saved studies, then choose it again.', + 'the Study workspace is closing. When it has closed, run “Codebook: Open Study Workspace”, then make your choice again.', + left: 'the Learning summary is not showing a replay. Open a replay from Saved studies, then make your choice again.', } as const /** What a dropped-choice message is built from. */ @@ -4338,6 +4338,8 @@ type DroppedChoiceContext = { const DROPPED_CHOICE_MESSAGES = [ { type: 'history', + openRequest: 21, + carriesStudy: true, build: ({ revision, studyId }: DroppedChoiceContext) => ({ type: 'history', revision, @@ -4347,6 +4349,8 @@ const DROPPED_CHOICE_MESSAGES = [ }, { type: 'node-history', + openRequest: 22, + carriesStudy: true, build: ({ revision, studyId }: DroppedChoiceContext) => ({ type: 'node-history', revision, @@ -4356,6 +4360,8 @@ const DROPPED_CHOICE_MESSAGES = [ }, { type: 'tab', + openRequest: undefined, + carriesStudy: false, build: ({ revision }: DroppedChoiceContext) => ({ type: 'tab', revision, @@ -4364,6 +4370,8 @@ const DROPPED_CHOICE_MESSAGES = [ }, { type: 'select', + openRequest: undefined, + carriesStudy: false, build: ({ revision, itemId }: DroppedChoiceContext) => ({ type: 'select', revision, @@ -4457,13 +4465,24 @@ for (const reason of [ assert.deepEqual( { notices: droppedChoiceNotices(harness), - entries: logged.map((entry) => entry.cause), + // The entries are copied into this realm's plain objects: the reader runs in the harness's own realm. + entries: logged.map((entry) => ({ ...entry })), + replies: harness.openRequestReplies(), }, { notices: [`Codebook did not carry out your choice: ${sentence}`], entries: [ - `The "${choice.type}" choice from the Learning summary was not carried out: ${sentence}`, + { + surface: 'choice:dropped', + ...(choice.carriesStudy ? { studyId: history.studyId } : {}), + cause: `The "${choice.type}" choice from the Learning summary was not carried out: ${sentence}`, + }, ], + // The pane that sent a choice with an open request is released whether or not it was carried out. + replies: + choice.openRequest === undefined + ? [] + : [{ webview: 'summary', openRequest: choice.openRequest }], }, ) } finally { @@ -4473,63 +4492,69 @@ for (const reason of [ }) // Value: protects=a Learning summary message that is no choice (the view's ready report or its keyboard-focus report) being dropped quietly, with no notification and no Output entry, whether the reader is closed, closing, has left the replay composition or closed while it initialized; fails_when=a stale report tells the learner a choice was not carried out or writes the drop entry (/ship pass 1 SM7 and pass 2 SM26 and SM27, Cycle 0056 pt3); why_new=the task 17 tests send only a node-history choice, so a notice for any dropped message would pass them; seam=workspaceHarness, readerSeams.receiveFromPanel with the Learning summary view, the harness's information messages, the diagnostics log; no_claim=the notification's look in the editor -for (const reason of [ +/** The quiet-drop cells: the keyboard-focus report is answered before the reader initializes, so only the ready report has a "closed while initializing" cell. */ +const QUIET_DROP_CELLS = [ 'closed', 'closing', 'left', 'closed while initializing', -] as const) - for (const report of [ +].flatMap((reason) => + [ { name: 'ready report', message: { type: 'ready' } }, { name: 'keyboard-focus report', message: { type: 'pane-focus', focused: true }, }, - ] as const) - test(`a Learning summary ${report.name} dropped because the reader is ${reason} tells the learner nothing and writes no entry`, async () => { - // Arrange - const harness = await workspaceHarness() - const logged: CodebookDiagnostic[] = [] - harness.reader.diagnostics = { - log: (entry) => logged.push(entry), - show: () => undefined, - } - try { - await openByOpenReplay(harness) - - // Act - // The keyboard-focus report is answered before the reader initializes, so only the ready report waits there. - if ( + ] + .filter( + (report) => + !( reason === 'closed while initializing' && - report.message.type === 'ready' - ) { - const held = holdNextInitialize(harness) - const received = sendFromSummary(harness, report.message) - await held.entered - await harness.reader.close() - held.release() - await received - } else { - if (reason === 'closed' || reason === 'closed while initializing') - await harness.reader.close() - if (reason === 'closing') harness.reader['closing'] = true - if (reason === 'left') await harness.reader.action('return-current') - await sendFromSummary(harness, report.message) - } + report.message.type === 'pane-focus' + ), + ) + .map((report) => ({ reason, report })), +) +for (const { reason, report } of QUIET_DROP_CELLS) + test(`a Learning summary ${report.name} dropped because the reader is ${reason} tells the learner nothing and writes no entry`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + await openByOpenReplay(harness) - // Assert - assert.deepEqual( - { - notices: droppedChoiceNotices(harness), - entries: logged.map((entry) => entry.surface), - }, - { notices: [], entries: [] }, - ) - } finally { - harness.reader['closing'] = false - await harness.close() + // Act + if (reason === 'closed while initializing') { + const held = holdNextInitialize(harness) + const received = sendFromSummary(harness, report.message) + await held.entered + await harness.reader.close() + held.release() + await received + } else { + if (reason === 'closed') await harness.reader.close() + if (reason === 'closing') harness.reader['closing'] = true + if (reason === 'left') await harness.reader.action('return-current') + await sendFromSummary(harness, report.message) } - }) + + // Assert + assert.deepEqual( + { + notices: droppedChoiceNotices(harness), + entries: logged.map((entry) => entry.surface), + }, + { notices: [], entries: [] }, + ) + } finally { + harness.reader['closing'] = false + await harness.close() + } + }) // Value: protects=an Application group the editor reports active later than the preparation's 150 ms wait: the open is refused with a report of the late activation, never a half-arranged reader; fails_when=the open succeeds with no report or ends in another failure, or the report no longer lists the group change the late activation caused (local open-code-review OCR-L2, Cycle 0056 pt3); why_new=task 17's test pinned only a report that arrives within the bound; seam=workspaceHarness with activateNewGroupLateAfterInsertion(four times the wait), the diagnostics log; no_claim=that Cursor ever reports later than the bound (N56 on 5b39b767 and 02acefaa: 13 opens, no refusal) test('a test replay whose Application group the editor activates after the preparation’s wait is refused with a placement report', async () => { @@ -4575,10 +4600,11 @@ test('a test replay whose Application group the editor activates after the prepa /** * Holds the reader's next `open()` at its start until the test releases it, so a test acts while an entry for a test replay waits in the closed reader. * @param harness - The workspace harness. + * @param refusal - When given, the held open throws it once released instead of running, as an editor that refuses the arrangement does. * @returns `entered` (settles when the open began), `release()` (lets it go on) and `restore()` (puts the reader's own open back). * @example const held = holdNextReaderOpen(harness); void harness.reader.openLearningReplay(id); await held.entered; held.release() */ -function holdNextReaderOpen(harness: Harness) { +function holdNextReaderOpen(harness: Harness, refusal?: Error) { const open = harness.reader.open.bind(harness.reader) let release = (): void => {} const gate = new Promise((resolve) => { @@ -4594,6 +4620,7 @@ function holdNextReaderOpen(harness: Harness) { isFirst = false entered() await gate + if (refusal) throw refusal } return open(...openArguments) } @@ -4615,7 +4642,9 @@ function holdNextReaderOpen(harness: Harness) { function heldBackStudyId(harness: Harness): string | undefined { // A renamed field would read `undefined` and let every "nothing is held back" assertion pass without meaning; the guard fails the test instead. assert.equal('heldBackReplay' in harness.reader, true) - return harness.reader['heldBackReplay']?.studyId + return harness.reader['heldBackReplay'] + ? harness.reader.inspect().view?.nodeHistory?.header.studyId + : undefined } // Value: protects=the replay a closed reader showed being drawn again in the Learning summary and the bottom panel when the entry for another replay opened the reader and then ended without showing its replay; fails_when=releasing the held-back replay publishes nothing, so both views stay on the opening state until an unrelated publish (local red team RT1, Cycle 0056 pt3); why_new=OCR-L3 kept the selection but left the views unpublished at the release; seam=the reader's openNodeHistory ending superseded after the open, the summary's and bottom panel's postMessage; no_claim=the native frames @@ -4648,8 +4677,8 @@ test('an Open replay of another test replay that ends after it opened the closed } }) -// Value: protects=one notification while the notice for a dropped Learning summary choice is still open, a new notification once the learner dismissed it, and a new one after the workspace entered the replay composition again, with an Output entry for every drop; fails_when=repeated clicks raise a notice each while the first is open, a dismissed notice is never told again, or entering the composition keeps the suppression (/ship pass 2 C2 and RT2-3, Cycle 0056 pt3); why_new=the first dedupe told once per reason until the workspace opened or closed, so a learner who dismissed the notice and clicked again got nothing; seam=workspaceHarness with information messages the test dismisses itself, readerSeams.receiveFromPanel with the Learning summary view, the diagnostics log; no_claim=the notification's look in the editor -test('repeated Saved studies choices dropped for one reason raise one notification while it is open, and tell again after it was dismissed or the composition was entered', async () => { +// Value: protects=every dropped Learning summary choice raising its notification and writing its Output entry, however often the learner repeats it; fails_when=a repeated drop raises no notice because an earlier one was told (the notice suppression removed in /ship pass 3 DN, which let a notice that auto-hid into the notification centre silence a reason for good), or a drop writes no entry; why_new=pass 2 pinned a suppression that is gone: the editor replaces an identical visible notification by itself; seam=workspaceHarness, readerSeams.receiveFromPanel with the Learning summary view, the harness's information messages, the diagnostics log; no_claim=that the editor shows one notification for identical texts (VS Code's notification model, checked in its 1.142 bundle) +test('every repeated Saved studies choice dropped for one reason raises its notification and writes its Output entry', async () => { // Arrange const harness = await workspaceHarness() const logged: CodebookDiagnostic[] = [] @@ -4657,15 +4686,9 @@ test('repeated Saved studies choices dropped for one reason raise one notificati log: (entry) => logged.push(entry), show: () => undefined, } - // Every information message stays open until the test dismisses it. - const dismissals = new Map void>() + // Every notice stays open, as one that auto-hid into the notification centre does. harness.answerInformationMessages( - () => - new Promise((resolve) => { - dismissals.set(harness.informationMessages.length - 1, () => - resolve(undefined), - ) - }), + () => new Promise(() => undefined), ) try { const history = await openByOpenReplay(harness) @@ -4676,46 +4699,17 @@ test('repeated Saved studies choices dropped for one reason raise one notificati studyId: history.studyId, openRequest: 31, }) - const dismissDroppedNotices = async (): Promise => { - for (const [index, dismiss] of dismissals) - if ( - harness.informationMessages[index]!.message.startsWith( - 'Codebook did not carry out your choice', - ) - ) - dismiss() - // The dismissal settles on microtasks. - await Promise.resolve() - await Promise.resolve() - } await harness.reader.close() // Act await choose() await choose() - const whileFirstIsOpen = [ - droppedChoiceNotices(harness).length, - logged.length, - ] - await dismissDroppedNotices() - await choose() - const afterDismissal = [droppedChoiceNotices(harness).length, logged.length] - await harness.reader.open() - await harness.reader.close() await choose() // Assert assert.deepEqual( - { - whileFirstIsOpen, - afterDismissal, - afterReopen: [droppedChoiceNotices(harness).length, logged.length], - }, - { - whileFirstIsOpen: [1, 2], - afterDismissal: [2, 3], - afterReopen: [3, 4], - }, + [droppedChoiceNotices(harness).length, logged.length], + [3, 3], ) } finally { await harness.close() @@ -4738,6 +4732,13 @@ for (const cell of [ holdsDuringEntry: true, shownAfterwards: 'none', }, + { + name: 'another replay whose entry is refused', + entryFor: 'other', + ends: 'refused', + holdsDuringEntry: true, + shownAfterwards: 'closed', + }, { name: 'the same replay whose entry is refused', entryFor: 'same', @@ -4776,7 +4777,12 @@ for (const cell of [ ) const closedReplay = await openByOpenReplay(harness) await harness.reader.close() - const held = holdNextReaderOpen(harness) + const held = holdNextReaderOpen( + harness, + cell.ends === 'refused' + ? new Error('The editor refused the arrangement.') + : undefined, + ) const drawn = recordDrawnTestReplays(harness.summary) const entry = harness.reader.openLearningReplay( cell.entryFor === 'same' ? closedReplay.studyId : otherReplay.studyId, @@ -4796,9 +4802,6 @@ for (const cell of [ await Promise.all([entry, returned]) } else { // Refused: the held open throws once released. - harness.reader.open = async (): Promise => { - throw new Error('The editor refused the arrangement.') - } held.release() await entry held.restore() @@ -4813,8 +4816,10 @@ for (const cell of [ heldDuringEntry, heldAfterwards, shownAfterwards, + refusalsTold: harness.errorMessages.length, + // The same replay's entry may legitimately draw it; another replay's entry never does. closedReplayDrawn: - cell.shownAfterwards === 'closed' + cell.entryFor === 'same' && cell.shownAfterwards === 'closed' ? undefined : drawn.includes(closedReplay.studyId), }, @@ -4823,6 +4828,7 @@ for (const cell of [ ? closedReplay.studyId : undefined, heldAfterwards: undefined, + refusalsTold: cell.ends === 'refused' ? 1 : 0, shownAfterwards: cell.shownAfterwards === 'closed' ? closedReplay.studyId @@ -4830,7 +4836,9 @@ for (const cell of [ ? newerReplay.studyId : undefined, closedReplayDrawn: - cell.shownAfterwards === 'closed' ? undefined : false, + cell.entryFor === 'same' && cell.shownAfterwards === 'closed' + ? undefined + : false, }, ) } finally { @@ -5130,3 +5138,108 @@ test('a publish that throws when the held-back replay is released is reported an await harness.close() } }) + +// Value: protects=the closed reader's replay, reselected while an older entry for another replay still reads, being drawn by the Learning summary and the bottom panel at once; fails_when=the hold on the closed reader's replay is matched by study id alone, so the reselected replay (the same id, newly opened) is dropped from both views until the older entry's stale read returns (Codex adversarial CX2, Cycle 0056 pt3); why_new=the held-back replay was released only when every entry ended and matched by id; seam=the reader's readNodeHistoryOpening held for the first entry, the summary's and bottom panel's postMessage; no_claim=the native frames +test('a closed reader’s replay that is chosen again while an older entry for another replay still reads is drawn at once and again after the older read returns', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const otherReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const closedReplay = await openByOpenReplay(harness) + await harness.reader.close() + const readOpening = harness.reader['readNodeHistoryOpening'].bind( + harness.reader, + ) + let release = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let isFirst = true + let entered = (): void => {} + const hasEntered = new Promise((resolve) => { + entered = resolve + }) + harness.reader['readNodeHistoryOpening'] = async (...readArguments) => { + if (isFirst) { + isFirst = false + entered() + await gate + } + return readOpening(...readArguments) + } + const summaryDrawn = recordDrawnTestReplays(harness.summary), + bottomPanelDrawn = recordDrawnTestReplays(harness.playbackPanel) + const older = harness.reader.openLearningReplay(otherReplay.studyId) + await hasEntered + + // Act: the learner chooses the closed reader's replay again; it loads while the older entry still reads. + const newer = harness.reader.openLearningReplay(closedReplay.studyId) + await waitUntil( + () => + summaryDrawn.at(-1) === closedReplay.studyId && + bottomPanelDrawn.at(-1) === closedReplay.studyId, + 'both views draw the chosen again replay while the older read waits', + READER_RESULT_BOUND_MS, + ) + const drawnBeforeRelease = [summaryDrawn.length, bottomPanelDrawn.length] + release() + await Promise.all([older, newer]) + + // Assert + assert.deepEqual( + { + summaryLast: summaryDrawn.at(-1), + bottomPanelLast: bottomPanelDrawn.at(-1), + otherReplayDrawn: + summaryDrawn.includes(otherReplay.studyId) || + bottomPanelDrawn.includes(otherReplay.studyId), + keptDrawing: + summaryDrawn.length >= drawnBeforeRelease[0]! && + bottomPanelDrawn.length >= drawnBeforeRelease[1]!, + }, + { + summaryLast: closedReplay.studyId, + bottomPanelLast: closedReplay.studyId, + otherReplayDrawn: false, + keptDrawing: true, + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=an Open replay of another replay on a closed reader posting each message of the chosen replay to the Learning summary once; fails_when=the release of the held-back replay resets the publish memory and republishes after the entry adopted its replay, so the summary receives the same message twice in a row and can reset its page state (red team RT3-3, Cycle 0056 pt3); why_new=the release republished whether or not the held selection was still the selected one; seam=the summary's postMessage with every message kept as text; no_claim=the native frames +test('an Open replay of another test replay after Close posts no message twice in a row to the Learning summary', async () => { + // Arrange + const harness = await workspaceHarness() + harness.reader.diagnostics = { log: () => undefined, show: () => undefined } + try { + const chosenReplay = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + await harness.reader.close() + const posted: string[] = [], + post = harness.summary.webview.postMessage.bind(harness.summary.webview) + harness.summary.webview.postMessage = async (message: StudyViewMessage) => { + posted.push(JSON.stringify(message)) + return post(message) + } + + // Act + await harness.reader.openLearningReplay(chosenReplay.studyId) + await landsOn(harness, 2, 'the chosen replay lands on its first stop') + + // Assert + assert.deepEqual( + posted.filter((text, index) => index > 0 && text === posted[index - 1]), + [], + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/utils/wikiUpdateWindow.ts b/extension/src/tests/utils/wikiUpdateWindow.ts index b64b02a8..a5d51098 100644 --- a/extension/src/tests/utils/wikiUpdateWindow.ts +++ b/extension/src/tests/utils/wikiUpdateWindow.ts @@ -148,6 +148,7 @@ export type HeldUpdateStep = * @param options.chatReply - What VS Code's chat command resolves with once it took the request, as it does with `blockOnResponse` when the chat's response completed (`{}`), completed with an error (`{ errorDetails }`) or waits for a confirmation (`{ type: 'confirmation' }`); a reply that waits for a confirmation, at once, when omitted, so the chat counts as working. A promise that never settles is a chat that never answers. * @param options.chatAnswerClock - Sleep handed to the start of the update in the chat (the hand-over of the request, the looks at the Wiki's output and the wait for the first answer), so a test can pass the bound of the session's start and the 30 second bound without waiting. * @param options.missingEditorCommands - Commands this editor does not have: the list of its commands leaves them out, and running one throws the editor's "command not found". An editor has the chat's commands when omitted. + * @param options.commandListFails - Makes the editor's list of its commands fail: `reject` rejects the promise, `throw` throws before returning one. * @param options.signWatchFails - Makes the editor throw this message when the extension registers its listener for opened documents. * @param options.editorCommand - What the editor does when the extension runs a command other than the chat request (the reveal of the chat, which is the chat command without a query, the editor's New Chat, the chat's Cancel and its "Remove All Queued", for example): it gets the command's name, and what it returns or throws is the command's result. Such a command returns nothing when omitted. * @param options.remoteName - The editor's `env.remoteName`, as a window connected to a remote host reports it (for example `ssh-remote`); absent for a local window. @@ -187,6 +188,7 @@ export async function openWikiUpdateWindow(options: { } editorCommand?: (name: string) => unknown missingEditorCommands?: string[] + commandListFails?: 'reject' | 'throw' signWatchFails?: string cursorAgent?: ( root: string, @@ -557,16 +559,25 @@ export async function openWikiUpdateWindow(options: { return { dispose(): void {} } }, // The commands of the chat a supported editor has, and its trust command, without those the test takes away. - getCommands: async () => - [ - 'workbench.trust.manage', - 'workbench.action.chat.open', - 'workbench.action.chat.newChat', - 'workbench.action.chat.cancel', - 'workbench.action.chat.removeAllPendingRequests', - ].filter( - (command) => !options.missingEditorCommands?.includes(command), - ), + getCommands: (): Promise => { + if (options.commandListFails === 'throw') + throw new Error('The command list is unavailable') + if (options.commandListFails === 'reject') + return Promise.reject( + new Error('The command list is unavailable'), + ) + return Promise.resolve( + [ + 'workbench.trust.manage', + 'workbench.action.chat.open', + 'workbench.action.chat.newChat', + 'workbench.action.chat.cancel', + 'workbench.action.chat.removeAllPendingRequests', + ].filter( + (command) => !options.missingEditorCommands?.includes(command), + ), + ) + }, executeCommand: async ( name: string, argument?: unknown, diff --git a/extension/src/utils/causeRootsFor.ts b/extension/src/utils/causeRootsFor.ts index 8960b728..5404ea72 100644 --- a/extension/src/utils/causeRootsFor.ts +++ b/extension/src/utils/causeRootsFor.ts @@ -4,19 +4,19 @@ import type { CauseRoots } from './sanitizeCause' /** * Gives the private roots a cause must not show for a set of storage folders and, when one is known, the opened repository, with the home directory always; the one place that decides what counts as private, so a new root is added once (F262). Called by {@link StudyService}'s `causeRoots`, by the environment for the failure of the node-test readiness search ({@link searchRepositoryVitest}) and by {@link NodeTestHistoryStore} for its summaries' reasons until a service gives its own. * @param storage - The extension's storage folders; each reads ``. - * @param workspace - The opened repository's root, if any; it and its real path read ``. + * @param workspace - The opened repository's root, or the roots of every opened workspace folder, if any; each and its real path read ``. * @returns The roots for {@link sanitizeCause}, the workspace and the home directory by every name ({@link privateRootNames}, {@link homeRootNames}). * @example causeRootsFor(['/Users/ada/Library/…/globalStorage/x'], '/Users/ada/repo') // => { storage: [...], workspace: ['/Users/ada/repo'], home: ['/Users/ada'] } */ export function causeRootsFor( storage: readonly string[], - workspace?: string, + workspace?: string | readonly string[], ): CauseRoots { return { storage, ...(workspace === undefined ? {} - : { workspace: privateRootNames(workspace) }), + : { workspace: [workspace].flat().flatMap(privateRootNames) }), home: homeRootNames(), } } diff --git a/extension/src/utils/placementRefusalReport.ts b/extension/src/utils/placementRefusalReport.ts index 6fe4e197..5ed9df8d 100644 --- a/extension/src/utils/placementRefusalReport.ts +++ b/extension/src/utils/placementRefusalReport.ts @@ -2,6 +2,7 @@ import { REFUSAL_REPORT_EVENTS, REFUSAL_REPORT_GROUP_TABS, REFUSAL_REPORT_LABEL_CHARS, + REFUSAL_REPORT_UNATTRIBUTED_CHECKS, } from '../constants' /** One tab as a refusal report names the active tab of a group: its label, input kind and whether it is a preview or a pinned tab. */ @@ -150,7 +151,7 @@ export function describePlacementRefusal(input: { ...(input.failedChecks === undefined ? [] : [ - `Failed checks: ${input.failedChecks.length ? input.failedChecks.join(', ') : 'none'}`, + `Failed checks: ${input.failedChecks.length ? input.failedChecks.join(', ') : REFUSAL_REPORT_UNATTRIBUTED_CHECKS}`, ]), `Armed ${Math.round(input.nowMs - input.armedAtMs)} ms before the refusal`, `Groups before: ${describeRefusalGroups(input.before).join(' | ')}`, diff --git a/extension/src/utils/registerLlmWikiCommands.ts b/extension/src/utils/registerLlmWikiCommands.ts index 7621fc3b..05fdaa4f 100644 --- a/extension/src/utils/registerLlmWikiCommands.ts +++ b/extension/src/utils/registerLlmWikiCommands.ts @@ -93,9 +93,13 @@ async function manageTrust(options: LlmWikiCommandOptions): Promise { } catch (cause) { options.recordFailure('Manage Workspace Trust could not run', cause) // Whether the editor has the command decides the notice, never the wording of its error: absent, the learner is pointed to the Command Palette; present, the command failed. - const editorCommands = await Promise.resolve( - options.editor.commands.getCommands(true), - ).catch((): string[] => []) + // A list that cannot be read (a rejection or a synchronous throw) leaves the command's presence unknown: the Command Palette route always works, so the hint is shown. + let editorCommands: readonly string[] = [] + try { + editorCommands = await options.editor.commands.getCommands(true) + } catch { + // Unknown list: assume the command is absent. + } void options.showNotice( 'information', editorCommands.includes(MANAGE_WORKSPACE_TRUST_COMMAND) From 577ba5e58fe6733094e79718acf27de4ea25d64a Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 13:51:37 +0900 Subject: [PATCH 23/31] docs: Cycle 0056 pt3: the packaged README says a late-reported Application group still opens the test replay, how Close ends, and that the LLM Wiki view's message and rows change in one frame --- extension/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extension/README.md b/extension/README.md index 53ca06d1..f944fc45 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A test replay also opens when the editor (Cursor included) reports the Application's new group, or its activation, a moment late; a change the editor reports later still refuses the open with `Native pane, group or focus changed while presentation waited` and the `presentation:placement-refused` entry described below. **Codebook: Close Study Workspace** ends without an error when the editor already closed the panes itself, and a Close made while an open still waits leaves the workspace closed. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice you make in the Learning summary that Codebook cannot carry out is not lost silently: the notice names the reason and what to do next. The notice starts `Codebook did not carry out your choice:` and ends with one of three reasons. `the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.` `the Study workspace is closing. When it has closed, run “Codebook: Open Study Workspace”, then make your choice again.` `the Learning summary is not showing a replay. Open a replay from Saved studies, then make your choice again.` (the workspace is open but shows the Live arrangement or the Study pane). Every dropped choice raises its notice again; the editor shows one notice for identical texts. **Output › Codebook** records one `choice:dropped` entry for every dropped choice, naming the study when the choice carries one. @@ -258,7 +258,9 @@ The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site - **`Output: `** shows the resolved folder with **Change Location** (the `codebook.wikiOutputDirectory` setting) whenever another row shows. A folder outside the project shows its absolute path. - A read that fails shows `Could not read the LLM Wiki state` with `Refresh to retry`; a click opens Output › Codebook, where the cause is written. -The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. The message above the tree changes in the same render as the rows. Inside the project the watch stands on the project folder from the start and stays there, also in a fresh project whose Wiki folder's parent does not exist yet, so creating the Wiki folder or deleting or renaming it or a folder above it is seen (never the home folder or a drive root: when the project folder is one of those, the watch stands on the nearest existing folder below it). An output folder outside the project is watched from its own parent only when that exists; the home folder and a file-system root are never watched. The editor reports nothing for the watched folder itself, so deleting or renaming the project folder (or a folder above it) shows after **Refresh** or the next time the view is shown; the same holds for the nearest existing folder that is watched when the project folder is your home folder or a root. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. +When the view's state changes, for example when a run starts or ends, the message above the tree and the rows change together in the same frame, not one before the other. + +The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. Inside the project the watch stands on the project folder from the start and stays there, also in a fresh project whose Wiki folder's parent does not exist yet, so creating the Wiki folder or deleting or renaming it or a folder above it is seen (never the home folder or a drive root: when the project folder is one of those, the watch stands on the nearest existing folder below it). An output folder outside the project is watched from its own parent only when that exists; the home folder and a file-system root are never watched. The editor reports nothing for the watched folder itself, so deleting or renaming the project folder (or a folder above it) shows after **Refresh** or the next time the view is shown; the same holds for the nearest existing folder that is watched when the project folder is your home folder or a root. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. ### Troubleshooting the LLM Wiki From b5227ce923e94ac77f49d41d9c458d448a2c1f75 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 13:51:38 +0900 Subject: [PATCH 24/31] chore: bump version and changelog (v0.2.49.0) Co-Authored-By: Claude --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 2 +- VERSION | 2 +- extension/package.json | 2 +- extension/scripts/tests/package.test.mjs | 2 +- extension/src/constants.ts | 2 +- 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aab4ee6..4efe5dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.2.49.0] - 2026-10-27 (development candidate) + +Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, last of its three pull requests: a saved test replay opens in Cursor every time it is chosen, the Saved studies list and the Learning summary say what is true around **Codebook: Close Study Workspace**, and the LLM Wiki view follows its folder, trusts through the editor's own command and changes its message with its rows. Every behaviour was also checked in the real editors (native trial N56, VS Code 1.142.0 and Cursor 3.27.12). + +**Saved test replays.** + +- **A saved test replay opens in Cursor each time you choose it**: choosing it from a Study pane that stands in a group with your own tabs, or while Cursor reports the Application's new group late, no longer refuses with `Native pane, group or focus changed while presentation waited`; five opens in a row open without a refusal. +- **A refused or failed open never holds up your next choice**: its notice is shown and recorded at once, and your next Saved studies choice or **Open replay** is carried out; a choice the Learning summary sends while the Study workspace is closed, closing or no longer showing a replay is not carried out, and you are told so in a notice that starts `Codebook did not carry out your choice:` and says what to do next, with one `choice:dropped` entry in **Output › Codebook**. +- **After Close Study Workspace the Saved studies list names no study** (it reads `Choose a saved study`), so choosing any entry opens it, the one the closed reader showed included; **Codebook: Open Study Workspace** still shows that replay again. +- **Opening another replay after Close never shows the closed one first**: the Learning summary goes from its opening state straight to the replay you chose. +- **Close Study Workspace no longer fails with `Webview is disposed`** after the editor closed the panes itself, and a Close made while an open is still waiting leaves the workspace closed. +- **A refused placement says why** in **Output › Codebook**: which call refused, which compared parts differed (or that none did), each group's front tab and the editor changes and Codebook's own effects since the guard was armed, with your folders' paths shortened. + +**The LLM Wiki view.** + +- **Message and rows change together**: at a run's start and end the message above the tree changes in the same frame as the rows. +- **The view follows its folder**: deleting or moving the Wiki folder, or any folder above it inside your project (for example `rm -rf .codebook`), brings back the welcome without **Refresh**. +- **Manage Workspace Trust opens the editor's trust editor** in both editors through `workbench.trust.manage`; if the editor lacks that command, the view points you to “Workspaces: Manage Workspace Trust” in the Command Palette, and if the command fails it says so and records the cause, with your folders' paths shortened, in **Output › Codebook**. + +**Compatibility.** The MCP API stays 1.8; the Wiki's published format, the marker format, stored learner data and the bundled skill (1.0.50) do not change. + ## [0.2.48.0] - 2026-10-27 (development candidate) Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, second of its three pull requests: the LLM Wiki view and the browser reader say what is true in the states the cycle's tasks list. The Cursor test-replay fix and the native trial N56 follow in the third. diff --git a/README.md b/README.md index aaedd996..0278be90 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Codebook contributes `debug.focusEditorOnBreak: false` as the native debugger de ## Implementation status -This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.48.0** once the second pull request of [Cycle 0056](https://github.com/laststance/codebook/issues/135) is merged; until then 0.2.47.0 ([PR #136](https://github.com/laststance/codebook/pull/136), the cycle's first pull request) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0056 is a bug-fix cycle that closes the defects Cycle 0055 carried, in three pull requests: the first makes an LLM Wiki start say what happens when it joins a start under way, is stopped or passes its bound, and release or write back what it claimed; the second the LLM Wiki view and the reader; the third a saved test replay that Cursor refused to open, with the native trial. [Cycle 0055](https://github.com/laststance/codebook/issues/132) is a feature cycle: **Codebook: Generate LLM Wiki** writes a project's first LLM Wiki through the editor's agent from Codebook's own entry (its first pull request), and an **LLM Wiki** view in the Activity Bar shows, opens and updates it (its second). Cycle 0053 was a feature cycle: a test inside a workspace package runs with that package's Vitest and config, as the package's own `vitest` would, while a test the repository root's config already selects keeps running from the root. Cycle 0054 is a bug-fix cycle that closes the three defects Cycle 0053 carried: a test that ends before a later declared stop says so, a package test's launch asks the repository root again whether its config now selects the test, and the native-trial bootstrap keeps a listing it could not release. Cycle 0051 opened a test replay in the learning-replay composition a browser replay has: the Learning summary in the chat's place, one transport for both replay families, and a Learning summary button that shows a hidden summary again. Cycle 0052 was a bug-fix cycle that closed every defect Cycle 0051 carried, in three pull requests: the first makes replays leave, open and reload without stale frames and makes their entries and exits behave as the learner chose; the second fixes a test replay's moves and its summary page; the third the live runtime's unreadable facts, a browser run's refusals and the tests' hygiene. The MCP API is 1.8. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. +This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.49.0** once the third pull request of [Cycle 0056](https://github.com/laststance/codebook/issues/135) is merged; until then 0.2.48.0 ([PR #137](https://github.com/laststance/codebook/pull/137), the cycle's second pull request) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0056 is a bug-fix cycle that closes the defects Cycle 0055 carried, in three pull requests: the first makes an LLM Wiki start say what happens when it joins a start under way, is stopped or passes its bound, and release or write back what it claimed; the second the LLM Wiki view and the reader; the third a saved test replay that Cursor refused to open, with the native trial. [Cycle 0055](https://github.com/laststance/codebook/issues/132) is a feature cycle: **Codebook: Generate LLM Wiki** writes a project's first LLM Wiki through the editor's agent from Codebook's own entry (its first pull request), and an **LLM Wiki** view in the Activity Bar shows, opens and updates it (its second). Cycle 0053 was a feature cycle: a test inside a workspace package runs with that package's Vitest and config, as the package's own `vitest` would, while a test the repository root's config already selects keeps running from the root. Cycle 0054 is a bug-fix cycle that closes the three defects Cycle 0053 carried: a test that ends before a later declared stop says so, a package test's launch asks the repository root again whether its config now selects the test, and the native-trial bootstrap keeps a listing it could not release. Cycle 0051 opened a test replay in the learning-replay composition a browser replay has: the Learning summary in the chat's place, one transport for both replay families, and a Learning summary button that shows a hidden summary again. Cycle 0052 was a bug-fix cycle that closed every defect Cycle 0051 carried, in three pull requests: the first makes replays leave, open and reload without stale frames and makes their entries and exits behave as the learner chose; the second fixes a test replay's moves and its summary page; the third the live runtime's unreadable facts, a browser run's refusals and the tests' hygiene. The MCP API is 1.8. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. The specification is [goal version 10](PRODUCT_GOAL.md), and the implementation targets external repositories. Historical fixture or coupled-Wiki success is not proof of that destination. diff --git a/VERSION b/VERSION index 8a3198e4..36647e2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.48.0 +0.2.49.0 diff --git a/extension/package.json b/extension/package.json index 3b40a827..201b0134 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "codebook", "description": "Connect an external agent to source-grounded codebase studies.", "publisher": "laststance", - "version": "0.2.48", + "version": "0.2.49", "private": true, "engines": { "vscode": "^1.120.0", diff --git a/extension/scripts/tests/package.test.mjs b/extension/scripts/tests/package.test.mjs index 37d30369..688fb043 100644 --- a/extension/scripts/tests/package.test.mjs +++ b/extension/scripts/tests/package.test.mjs @@ -95,7 +95,7 @@ test('a real VSIX loads outside the checkout and excludes bundled applications a // Assert assert.equal(receipt.isolatedModuleLoad, true) assert.equal(receipt.isolatedWikiPageWorker, true) - assert.equal(checked.extension.version, '0.2.48') + assert.equal(checked.extension.version, '0.2.49') assert.equal(checked.extension.apiVersion, '1.8') assert.equal(checked.entries, 33) assert.equal(receipt.dependencies.length, 16) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 05713679..f7ee5ea7 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -8,7 +8,7 @@ import { CAPTURE_TIMEOUT_MS } from './runtimeSupport/constants' * 1.8 changed node-test outcomes: a passing test whose history sealed complete reads `demonstrated` in `finalOutcome`, `studyOutcome` and `recordedStudyOutcome` (1.7 stopped at `partial`), its sealed history is a test replay (`recordingAvailable` and `replayAvailable`), and `nodeTest` adds `testResult`, `learningCoverage` and `learningMissing`. A node-test start's deterministic refusals arrive as the run's `error` before its first stop, a failed history seal makes `codebook_stop` answer `HISTORY_SEAL_FAILED`, and `codebook_playback` refuses a test replay with `PLAYBACK_PROFILE_UNSUPPORTED`. */ export const API_VERSION = '1.8' -export const EXTENSION_VERSION = '0.2.48' +export const EXTENSION_VERSION = '0.2.49' export const CONFIGURED_MINIMUM_EXTENSION_VERSION = '0.2.0' export const NODE_TEST_MINIMUM_EXTENSION_VERSION = '0.2.12' export const LOOPBACK_ADDRESS = '127.0.0.1' From 1074b351d35cbc79168fba5ade67ec2790416280 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 14:18:41 +0900 Subject: [PATCH 25/31] fix: Cycle 0056 pt3: pnpm health builds Fallow's graph from the current source instead of a cache an earlier run left The exact-HEAD gate on b5227ce9 failed `pnpm health` with 35 findings beyond the measured baseline: unchanged files such as sourceImportBindings.ts scored their unmatched arrow functions as untested (estimated 0%). Two runs that reused the checkout's Fallow graph cache (extension/.fallow) gave 35; the next run, whose cache was invalidated, gave 0 on the same source, as did origin/main in a fresh worktree. The measured Fallow runs now pass --no-cache (about 2 s more). Test red without the fix: the fixture checkout held a `.fallow` folder after a passing run. --- extension/scripts/checkHealth.mjs | 2 ++ extension/scripts/tests/health.test.mjs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/extension/scripts/checkHealth.mjs b/extension/scripts/checkHealth.mjs index c3d789b0..140c5285 100644 --- a/extension/scripts/checkHealth.mjs +++ b/extension/scripts/checkHealth.mjs @@ -409,6 +409,8 @@ function fallowArguments(reference, coverage, format) { 'health', '--fail-on-issues', '--quiet', + // Fallow's incremental cache in the checkout can outlive the source it describes: a reused graph cache once scored functions the tests reach as unreached (Cycle 0056 pt3). Each measured run builds its graph from the current source. + '--no-cache', '--baseline', reference.baseline.file, '--coverage', diff --git a/extension/scripts/tests/health.test.mjs b/extension/scripts/tests/health.test.mjs index d99c4eac..ccb110f4 100644 --- a/extension/scripts/tests/health.test.mjs +++ b/extension/scripts/tests/health.test.mjs @@ -310,6 +310,22 @@ test('a complete passing run reports health and discloses its skipped test', asy assert.deepEqual(await runDirectoriesIn(fixture.temporary), []) }) +// Value: protects=a maintainer or CI job that reads the exit of `pnpm health` as the verdict of the current source; fails_when=the measured Fallow runs read or write Fallow's incremental cache in the checkout (`.fallow/`), so a graph cache left by earlier runs decides which functions tests reach and the CRAP estimates of unmatched functions (Cycle 0056 pt3's gate: 35 findings with a reused cache, 0 with a fresh graph, same source); why_new=Cycle 0056 pt3 gate; seam=the fixture checkout's `.fallow` folder after a passing run; no_claim=how Fallow's incremental cache goes stale +test("a measured run leaves no Fallow cache in the checkout, so an earlier run's cache never decides its verdict", async (context) => { + // Arrange + const fixture = await createFixture(context) + + // Act + const result = await runChecker(fixture) + + // Assert: the run passed, and Fallow wrote no cache beside the sources (it read none either: `--no-cache` disables both). + assert.equal(result.code, 0, result.stderr) + assert.deepEqual( + (await readdir(fixture.root)).filter((name) => name === '.fallow'), + [], + ) +}) + test('a failing test refuses health instead of scoring the coverage of a failed run', async (context) => { // Arrange const fixture = await createFixture(context, { From 41da79d9bec759ac76ed928c6ea2712cb186392d Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 15:49:32 +0900 Subject: [PATCH 26/31] fix: Cycle 0056 pt3: resolve CodeRabbit review findings on PR #138 - CHANGELOG: the native-check claim names what N56 checked, on which candidates and hosts, and links the Issue comments with the evidence; "every time" claims narrowed. - README and CHANGELOG: after Close, an entry that cannot be opened and was chosen before stays chosen with its delete control. - studyWorkspaceHarness: the delayed next-tab activation looks its group up again, so a rebuild of the group objects cannot leave it stale (latent: a probe showed no current cell reached a stale group); the shared-group test also runs with group-object rebuilds (Cursor's model). - The reselected-replay test drops its vacuous keptDrawing check (>= held without any publish) and is named for what it proves: drawn at once and still drawn after the older read returns. --- CHANGELOG.md | 8 +- extension/README.md | 2 +- extension/src/tests/studyWorkspaceHarness.ts | 4 +- ...tudyWorkspaceTestReplayComposition.test.ts | 125 +++++++++--------- 4 files changed, 69 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efe5dcb..a6c5f13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ ## [0.2.49.0] - 2026-10-27 (development candidate) -Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, last of its three pull requests: a saved test replay opens in Cursor every time it is chosen, the Saved studies list and the Learning summary say what is true around **Codebook: Close Study Workspace**, and the LLM Wiki view follows its folder, trusts through the editor's own command and changes its message with its rows. Every behaviour was also checked in the real editors (native trial N56, VS Code 1.142.0 and Cursor 3.27.12). +Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, last of its three pull requests: a saved test replay opens in Cursor when it is chosen, the Saved studies list and the Learning summary say what is true around **Codebook: Close Study Workspace**, and the LLM Wiki view follows its folder, trusts through the editor's own command and changes its message with its rows. The native trial N56 checked part of this in the real editors, on this part's development candidates (`02acefaa`, `7ff3ab57`, `932cdae3`), not on the released build: VS Code 1.142.0 and Cursor 3.27.12 on macOS 26 (a virtual machine), with the development target `nextjs-dashboard`. It covered the test replay's opens and the Saved studies list after Close in Cursor, the trust command in both editors, and the view's message and rows and a deleted Wiki folder in VS Code; the other changes are covered by tests only. Each step's host, candidate and result, and the hashes of its recordings (kept private), are in [Issue #135's comments](https://github.com/laststance/codebook/issues/135#issuecomment-6431339240). **Saved test replays.** -- **A saved test replay opens in Cursor each time you choose it**: choosing it from a Study pane that stands in a group with your own tabs, or while Cursor reports the Application's new group late, no longer refuses with `Native pane, group or focus changed while presentation waited`; five opens in a row open without a refusal. +- **A saved test replay opens in Cursor when you choose it**: choosing it from a Study pane that stands in a group with your own tabs, or while Cursor reports the Application's new group a moment late, no longer refuses with `Native pane, group or focus changed while presentation waited`; in N56, five opens in a row opened without a refusal. - **A refused or failed open never holds up your next choice**: its notice is shown and recorded at once, and your next Saved studies choice or **Open replay** is carried out; a choice the Learning summary sends while the Study workspace is closed, closing or no longer showing a replay is not carried out, and you are told so in a notice that starts `Codebook did not carry out your choice:` and says what to do next, with one `choice:dropped` entry in **Output › Codebook**. -- **After Close Study Workspace the Saved studies list names no study** (it reads `Choose a saved study`), so choosing any entry opens it, the one the closed reader showed included; **Codebook: Open Study Workspace** still shows that replay again. +- **After Close Study Workspace the Saved studies list names no study** (it reads `Choose a saved study`), so choosing any entry that can be opened opens it, the one the closed reader showed included; **Codebook: Open Study Workspace** still shows that replay again. An entry that cannot be opened and that you chose before the Close stays chosen with its delete control. - **Opening another replay after Close never shows the closed one first**: the Learning summary goes from its opening state straight to the replay you chose. - **Close Study Workspace no longer fails with `Webview is disposed`** after the editor closed the panes itself, and a Close made while an open is still waiting leaves the workspace closed. - **A refused placement says why** in **Output › Codebook**: which call refused, which compared parts differed (or that none did), each group's front tab and the editor changes and Codebook's own effects since the guard was armed, with your folders' paths shortened. @@ -40,7 +40,7 @@ Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a - a link whose target is missing; - a path that cannot be read (with its error code, also when the disk does not answer within 3 seconds); - a folder in a remote window (`Reveal Wiki Folder opens folders on this computer only; the Wiki folder is on . …`). - A WSL window reveals the folder like a local one. + A WSL window reveals the folder like a local one. - **View actions have decided names and icons**: inline actions in Title Case (**Stop Watching** with an eye-closed icon, **Try Again** with a restart icon, **Change Location**, **Open Chat**, **Open Folder**, **Show Output**, **Copy Recovery Request**); **Cancel** keeps the stop icon because it stops the agent. - **The welcome names the default folder**: `The Wiki is written to .codebook/wiki unless the setting codebook.wikiOutputDirectory names another folder.` with the **Change location** link, and a click on the **Output** row opens the same setting. diff --git a/extension/README.md b/extension/README.md index f944fc45..701349ac 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A test replay also opens when the editor (Cursor included) reports the Application's new group, or its activation, a moment late; a change the editor reports later still refuses the open with `Native pane, group or focus changed while presentation waited` and the `presentation:placement-refused` entry described below. **Codebook: Close Study Workspace** ends without an error when the editor already closed the panes itself, and a Close made while an open still waits leaves the workspace closed. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A test replay also opens when the editor (Cursor included) reports the Application's new group, or its activation, a moment late; a change the editor reports later still refuses the open with `Native pane, group or focus changed while presentation waited` and the `presentation:placement-refused` entry described below. **Codebook: Close Study Workspace** ends without an error when the editor already closed the panes itself, and a Close made while an open still waits leaves the workspace closed. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry that can be opened opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. An entry that cannot be opened (below) and that you chose before the Close stays chosen, with its delete control. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice you make in the Learning summary that Codebook cannot carry out is not lost silently: the notice names the reason and what to do next. The notice starts `Codebook did not carry out your choice:` and ends with one of three reasons. `the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.` `the Study workspace is closing. When it has closed, run “Codebook: Open Study Workspace”, then make your choice again.` `the Learning summary is not showing a replay. Open a replay from Saved studies, then make your choice again.` (the workspace is open but shows the Live arrangement or the Study pane). Every dropped choice raises its notice again; the editor shows one notice for identical texts. **Output › Codebook** records one `choice:dropped` entry for every dropped choice, naming the study when the choice carries one. diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index e04d5518..cee916b6 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -3453,7 +3453,9 @@ export async function workspaceHarness( const activateNextTab = (): void => { if (pendingNextTabActivation !== activateNextTab) return pendingNextTabActivation = undefined - sharedGroup.activeTab = nextTab + // Looked up again: a rebuild of the group objects since the disposal would leave `sharedGroup` stale. + const holder = groups.find((group) => group.tabs.includes(nextTab)) + if (holder) holder.activeTab = nextTab for (const listener of tabChangeListeners) listener({ opened: [], closed: [], changed: [nextTab] }) } diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index a0f2bc31..3edae1c9 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -163,66 +163,68 @@ function compositionOf(harness: Harness) { } } -// Value: protects=N55-F4 refusal 2: a test replay chosen in a Study pane that sits in the learner's own group opens, although Cursor reports the group's next tab as active before it closes the disposed Study tab; fails_when=the entry reads the grid while the disposed Study tab is still listed and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 (the refusal came from Codebook's own disposal, not from the learner); seam=workspaceHarness with activateNextTabBeforeDisposedPaneTabCloses and addForeignDraft in the Study pane's group; no_claim=Cursor's own event order (N56 step 1 checks it natively) -test('a test replay chosen in a Study pane that shares the learner’s group opens without a placement refusal', async () => { - // Arrange - const harness = await workspaceHarness() - const logged: CodebookDiagnostic[] = [] - harness.reader.diagnostics = { - log: (entry) => logged.push(entry), - show: () => undefined, - } - harness.activateNextTabBeforeDisposedPaneTabCloses() - try { - const history = await saveTwoStopNodeHistory( - harness.service.nodeHistoryStore!, - ) - await harness.reader.action('retry') - await harness.reader.open() - const studyColumn = harness.panelColumn('codebook.studyContext') - assert.notEqual(studyColumn, undefined) - // The learner's own tab shares the Study pane's group, so the group is no one Codebook created. - harness.addForeignDraft(studyColumn) +// Value: protects=N55-F4 refusal 2: a test replay chosen in a Study pane that sits in the learner's own group opens, although Cursor reports the group's next tab as active before it closes the disposed Study tab; fails_when=the entry reads the grid while the disposed Study tab is still listed and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 (the refusal came from Codebook's own disposal, not from the learner); seam=workspaceHarness with activateNextTabBeforeDisposedPaneTabCloses and addForeignDraft in the Study pane's group, and in the second cell replaceGroupObjectsOnTabChanges (Cursor's extension host gives groups new objects at tab changes; the delayed next-tab activation looks its group up again, PR #138 review); no_claim=Cursor's own event order (N56 step 1 checks it natively) +for (const rebuildsGroupObjects of [false, true]) + test(`a test replay chosen in a Study pane that shares the learner’s group opens without a placement refusal${rebuildsGroupObjects ? ', also when the editor rebuilds its group objects at every tab change' : ''}`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.activateNextTabBeforeDisposedPaneTabCloses() + if (rebuildsGroupObjects) harness.replaceGroupObjectsOnTabChanges() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + const studyColumn = harness.panelColumn('codebook.studyContext') + assert.notEqual(studyColumn, undefined) + // The learner's own tab shares the Study pane's group, so the group is no one Codebook created. + harness.addForeignDraft(studyColumn) - // Act - harness.receiveContext({ - type: 'node-history', - revision: lastView(harness)!.revision, - studyId: history.studyId, - }) - // A refusal ends the entry without a landing, so the wait ends on either result. - await waitUntil( - () => - lastView(harness)?.nodeHistoryPosition?.sequence === 2 || - logged.some( + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + // A refusal ends the entry without a landing, so the wait ends on either result. + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 || + logged.some( + (entry) => entry.surface === 'presentation:placement-refused', + ), + 'the Saved studies choice lands on the first stop or is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + logged.filter( (entry) => entry.surface === 'presentation:placement-refused', ), - 'the Saved studies choice lands on the first stop or is refused', - READER_RESULT_BOUND_MS, - ) - - // Assert - assert.deepEqual( - logged.filter( - (entry) => entry.surface === 'presentation:placement-refused', - ), - [], - ) - await landsOn( - harness, - 2, - 'the Saved studies choice lands on the first stop', - ) - assert.deepEqual(compositionOf(harness), { - studyPane: false, - application: true, - summary: true, - family: 'test', - }) - } finally { - await harness.close() - } -}) + [], + ) + await landsOn( + harness, + 2, + 'the Saved studies choice lands on the first stop', + ) + assert.deepEqual(compositionOf(harness), { + studyPane: false, + application: true, + summary: true, + family: 'test', + }) + } finally { + await harness.close() + } + }) // Value: protects=N55-F4's silent drop: a Saved studies choice sent from the Learning summary after Close Study Workspace is recorded in Output › Codebook with its study, and the sending pane gets its open-request-settled reply; fails_when=the reader drops the choice with no Output entry and no reply, so the learner sees nothing and the pane keeps its opening choice; why_new=Cycle 0056 task 17 (receive's owner gate returned silently for the Learning summary of a closed workspace); seam=workspaceHarness (VS Code), the Learning summary view's listener, the diagnostics log, the harness's recorded open-request replies; no_claim=the wording the learner reads in a real Output channel test('a Saved studies choice sent from the Learning summary after the Study workspace closed is recorded and releases the pane', async () => { @@ -5139,8 +5141,8 @@ test('a publish that throws when the held-back replay is released is reported an } }) -// Value: protects=the closed reader's replay, reselected while an older entry for another replay still reads, being drawn by the Learning summary and the bottom panel at once; fails_when=the hold on the closed reader's replay is matched by study id alone, so the reselected replay (the same id, newly opened) is dropped from both views until the older entry's stale read returns (Codex adversarial CX2, Cycle 0056 pt3); why_new=the held-back replay was released only when every entry ended and matched by id; seam=the reader's readNodeHistoryOpening held for the first entry, the summary's and bottom panel's postMessage; no_claim=the native frames -test('a closed reader’s replay that is chosen again while an older entry for another replay still reads is drawn at once and again after the older read returns', async () => { +// Value: protects=the closed reader's replay, reselected while an older entry for another replay still reads, being drawn by the Learning summary and the bottom panel at once, and still being the drawn replay once the older entry's read returns; fails_when=the hold on the closed reader's replay is matched by study id alone, so the reselected replay (the same id, newly opened) is dropped from both views until the older entry's stale read returns, or the older entry draws its own replay when its read returns (Codex adversarial CX2, Cycle 0056 pt3); why_new=the held-back replay was released only when every entry ended and matched by id; seam=the reader's readNodeHistoryOpening held for the first entry, the summary's and bottom panel's postMessage; no_claim=the native frames +test('a closed reader’s replay that is chosen again while an older entry for another replay still reads is drawn at once and stays drawn after the older read returns', async () => { // Arrange const harness = await workspaceHarness() harness.reader.diagnostics = { log: () => undefined, show: () => undefined } @@ -5184,7 +5186,6 @@ test('a closed reader’s replay that is chosen again while an older entry for a 'both views draw the chosen again replay while the older read waits', READER_RESULT_BOUND_MS, ) - const drawnBeforeRelease = [summaryDrawn.length, bottomPanelDrawn.length] release() await Promise.all([older, newer]) @@ -5196,15 +5197,11 @@ test('a closed reader’s replay that is chosen again while an older entry for a otherReplayDrawn: summaryDrawn.includes(otherReplay.studyId) || bottomPanelDrawn.includes(otherReplay.studyId), - keptDrawing: - summaryDrawn.length >= drawnBeforeRelease[0]! && - bottomPanelDrawn.length >= drawnBeforeRelease[1]!, }, { summaryLast: closedReplay.studyId, bottomPanelLast: closedReplay.studyId, otherReplayDrawn: false, - keptDrawing: true, }, ) } finally { From 4c9d0b58095bad9a487ad88fcbe8d49b6ff5956c Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 16:08:08 +0900 Subject: [PATCH 27/31] fix: Cycle 0056 pt3: resolve CodeRabbit's outside-diff findings on PR #138 - studyWorkspaceHarness: a late group report follows its group through the fake model's rebuilds (every group copy records its successor), so the late activation of the inserted Application group is no longer dropped when the editor rebuilds its group objects. The H2-b test also runs with group-object rebuilds (Cursor's model) and asserts that the late report reached the live model: red without the fix (lateGroupActivationsReported 0, expected 1). - README: a refused placement whose compared parts all match reads `Failed checks: unattributed (none of the compared parts differ)`. --- extension/README.md | 2 +- extension/src/tests/studyWorkspaceHarness.ts | 87 ++++++++++++++--- ...tudyWorkspaceTestReplayComposition.test.ts | 97 ++++++++++--------- 3 files changed, 122 insertions(+), 64 deletions(-) diff --git a/extension/README.md b/extension/README.md index 701349ac..4d10add1 100644 --- a/extension/README.md +++ b/extension/README.md @@ -100,7 +100,7 @@ The executed source shows in one **recorded-source tab**, named after the stop's The time slider marks the recording between its first and last event as unrecorded, with a tick at each stop whose recorded source verifies, under `Ticks mark recorded stops; source and debugger are not recorded between them.` (a test journal records when a stop paused, not when the test resumed). **Play** moves from event to event and waits at most 1.5 seconds for the next one, also across an unrecorded interval, which it neither pauses at nor asks about; each surface shows the selected event's own time. -Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused (and, for the focus guard, the name of the call that armed it and the comparisons that failed), the editor groups before and after with the tab each group showed, the focused tab when the guard was armed against the active tab at the refusal, the changes the editor reported meanwhile (group changes, tab operations, view-state reports of Codebook's panes and the active text editor) and the reveals, disposals, editor commands and shown documents Codebook itself issued meanwhile; the sentence shown in the Study pane is unchanged. +Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused (and, for the focus guard, the name of the call that armed it and the comparisons that failed, or `Failed checks: unattributed (none of the compared parts differ)` when none of them differ), the editor groups before and after with the tab each group showed, the focused tab when the guard was armed against the active tab at the refusal, the changes the editor reported meanwhile (group changes, tab operations, view-state reports of Codebook's panes and the active text editor) and the reveals, disposals, editor commands and shown documents Codebook itself issued meanwhile; the sentence shown in the Study pane is unchanged. The bundled skill (since 1.0.48) routes a question about a test, or about library behavior a test exercises, to the `node-test` profile, and the chat explains each test stop in the chat. A value the chat asks for at a test stop is also read when it is a name the stopped file imports (`revalidatePath.mock.calls.length` of a mocked `next/cache`, for example), through the module Vitest binds it to, as the export its import declaration names: an alias reads the module's original export and a default import its default export, each from that declaration's own module, never from another module that happens to export the same name; a namespace import, a name the file does not import, and an import whose module the stop cannot see stay unavailable rather than guessed. A getter on the asked path (a Vitest mock's `mock`, for example) runs once, as it does when you expand it in the Variables view; a getter the debugger cannot expand, or expands to no single value, leaves only that value unavailable, never its `(...)` placeholder, and a local variable whose value cannot be read still takes priority over an import of the same name. A required value that still cannot be read no longer ends the run: the stop keeps its other values and names the missing one among its debugger limitations, and the `codebook_continue` that reached it (or your Continue in the Study pane) answers `EVIDENCE_UNAVAILABLE` naming it while the run stays paused there, so the chat, or you, can continue without it; the run's history then seals partial, with a reason that names the value and its stop, and the study reads partial although the test passed. A run that ends with an `error` before its first stop reached no stop and explains nothing (a refusal before the launch ran no test; a test that exited before that stop names its exit code or signal): the chat starts it again only where the published recovery of that error's code allows (`codebook://help/errors`), and it treats any text the error quotes, such as test or tool output, as data. Declared stops are reached in the order the test runs their lines: a later stop on a line the test ran before the previous stop is reached only if the test runs that line again after it (in a loop, for example); otherwise it is never reached, and the continuation then says `Owned Node test exited with code (or signal ) before declared stop .`, naming that stop, instead of only that the debug session ended; the node-test help tells the chat to choose stops in that order, and names the exit code or signal of a test that ends before a stop. A test that ends before its first declared stop says `Owned Node test exited with code (or signal ) before declared stop 1.`, then that the first declared stop is reached only when the test runs its line. That sentence comes only when the test ran on and did not reach the stop: a refusal before the test runs on (after you stopped the debug session yourself, for example) and a failure while a reached stop's values are captured keep their own words. The chat claims saved learning only when the run reads `studyOutcome` `demonstrated`, `nodeTest.learningCoverage` `complete` and an empty `nodeTest.captureMissing` (a run resource without those fields supports no such claim), says when a test did not pass and names its exit code or signal, and points you to **Save learning replay** and **Open replay**. When the connection is unavailable, it sends you to **Codebook: Set Up AI Chat**, and in a chat of VS Code's Copilot harness (the default for a new chat), which cannot reach Codebook's tools, to open a Local chat session. diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index cee916b6..8a9d3641 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1104,6 +1104,43 @@ export async function workspaceHarness( report: () => void atRead: () => boolean }[] = [] + /** How many late group activations reached the fake editor's live group model; see `lateGroupActivationsReported`. */ + let reportedLateGroupActivations = 0 + /** The copy that replaced each group object at a rebuild of the fake group model, so a reference taken before the rebuild finds its group again ({@link currentGroupOf}). */ + const groupSuccessors = new WeakMap< + (typeof groups)[number], + (typeof groups)[number] + >() + /** + * Records that a copy replaces a group object in the fake group model, as Cursor's extension host gives its groups new objects; every rebuild of the model goes through here. + * @returns The copy, for the rebuilt list. + * @example groups.map((group) => replacedBy(group, { ...group, tabs: [...group.tabs] })) + */ + function replacedBy( + previous: (typeof groups)[number], + next: (typeof groups)[number], + ): (typeof groups)[number] { + groupSuccessors.set(previous, next) + return next + } + /** + * Follows a group object through the rebuilds since it was taken to the object the fake model holds now; called by late reports that name a group taken before they run. + * @returns The live object, or `undefined` when the group left the model. + * @example currentGroupOf(inserted) // => the inserted group's object after two rebuilds + */ + function currentGroupOf( + group: (typeof groups)[number], + ): (typeof groups)[number] | undefined { + let current = group + // A chain of rebuilds: each copy may itself have been replaced since. + for ( + let next = groupSuccessors.get(current); + next; + next = groupSuccessors.get(current) + ) + current = next + return groups.includes(current) ? current : undefined + } // Cursor 3.23.12 keeps naming the chat's group as the active tab group after `workbench.action.focusFirstEditorGroup` gave the main editor the keyboard. Only a group event corrects the name: a whole tab-model rebuild names the active group (`workbench.desktop.main.js` 17269:31013, `extensionHostProcess.js` 131:33177), as does a group's activation (17269:28123, 131:33740); a tab notification is a tab operation, which leaves the named group as it is (131:34068), so it does not (Cycle 0046 row 673). let staleNamedGroup: (typeof groups)[number] | undefined // The tab that had the keyboard was closed while the focus was to be preserved, or its pane was disposed: no editor has the keyboard, the page body does (Cursor 3.23.12 after Close Study Workspace, Cycle 0045 obs15 P4), until something is focused again. @@ -1282,6 +1319,7 @@ export async function workspaceHarness( pendingLateGroupActivations.splice(index, 1) const activated = group() if (!activated || !groups.includes(activated)) return + reportedLateGroupActivations++ activeTabGroup = activated changedGroups({ opened: [], closed: [], changed: [activated] }) } @@ -1394,7 +1432,10 @@ export async function workspaceHarness( const replacements = new Map( groups .filter((group) => group !== opened) - .map((group) => [group, { ...group, tabs: [...group.tabs] }]), + .map((group) => [ + group, + replacedBy(group, { ...group, tabs: [...group.tabs] }), + ]), ) /** Names the new object of a group that survived, and leaves a group that is gone as it is. */ const replaced = (group: (typeof groups)[number]) => @@ -4150,8 +4191,8 @@ export async function workspaceHarness( const inserted = activeTabGroup // The report comes after the preparation's focus return has left the new group, so a guard armed by then sees the keyboard move there. queueLateGroupActivation( - () => inserted, - () => activeTabGroup !== inserted, + () => currentGroupOf(inserted), + () => activeTabGroup !== currentGroupOf(inserted), newGroupActivationDelayMs, ) } @@ -4550,7 +4591,10 @@ export async function workspaceHarness( .map((group) => group === separateEditorPart?.group ? group - : { ...group, tabs: [...group.tabs] }, + : replacedBy(group, { + ...group, + tabs: [...group.tabs], + }), ), ) if (cursorTwoEventContraction) @@ -4604,7 +4648,10 @@ export async function workspaceHarness( .map((group) => group === separateEditorPart?.group ? group - : { ...group, tabs: [...group.tabs] }, + : replacedBy(group, { + ...group, + tabs: [...group.tabs], + }), ), ) } @@ -6035,6 +6082,12 @@ export async function workspaceHarness( * @example harness.activateNewGroupLateAfterInsertion() * @example harness.activateNewGroupLateAfterInsertion(600) // later than the preparation's wait */ + /** + * Counts the late group activations the fake editor reported to a group of its live model; a report whose group is no longer in the model is dropped and not counted. + * @returns How many late activations reached a live group so far. + * @example harness.lateGroupActivationsReported() // => 1 after activateNewGroupLateAfterInsertion and one Application group insertion + */ + lateGroupActivationsReported: () => reportedLateGroupActivations, activateNewGroupLateAfterInsertion(delayMs?: number): void { activatesNewGroupLateAfterInsertion = true newGroupActivationDelayMs = delayMs @@ -7688,18 +7741,20 @@ export async function workspaceHarness( groups.splice( 0, groups.length, - ...groups.map((group) => ({ - ...group, - tabs: group.tabs.map((tab) => { - const refreshed = { - ...tab, - label: preserveLabels ? tab.label : 'Recorded application', - input: resyncedInput(tab.input), - } - refreshedTabs.set(tab, refreshed) - return refreshed + ...groups.map((group) => + replacedBy(group, { + ...group, + tabs: group.tabs.map((tab) => { + const refreshed = { + ...tab, + label: preserveLabels ? tab.label : 'Recorded application', + input: resyncedInput(tab.input), + } + refreshedTabs.set(tab, refreshed) + return refreshed + }), }), - })), + ), ) // A full tab-model resync changes API objects, never the native webview handle that later reveal and dispose operate on. for (const current of panels) { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 3edae1c9..75266297 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -325,56 +325,59 @@ test('a test replay chosen while the Application is alone in its group opens wit } }) -// Value: protects=N55-F4 H2-b: a test replay opens although the editor reports the Application's new group active only after newGroupAbove answered; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateNewGroupLateAfterInsertion, the diagnostics log; no_claim=that Cursor reports it this way (N56 step 1 checks natively) -test('a test replay whose Application group the editor activates late opens without a placement refusal', async () => { - // Arrange - const harness = await workspaceHarness() - const logged: CodebookDiagnostic[] = [] - harness.reader.diagnostics = { - log: (entry) => logged.push(entry), - show: () => undefined, - } - harness.activateNewGroupLateAfterInsertion() - try { - const history = await saveTwoStopNodeHistory( - harness.service.nodeHistoryStore!, - ) - await harness.reader.action('retry') - await harness.reader.open() +// Value: protects=N55-F4 H2-b: a test replay opens although the editor reports the Application's new group active only after newGroupAbove answered; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateNewGroupLateAfterInsertion, the diagnostics log, and in the second cell replaceGroupObjectsOnTabChanges (Cursor's extension host gives groups new objects at tab changes; the late report follows the inserted group through them, PR #138 review); no_claim=that Cursor reports it this way (N56 step 1 checks natively) +for (const rebuildsGroupObjects of [false, true]) + test(`a test replay whose Application group the editor activates late opens without a placement refusal${rebuildsGroupObjects ? ', also when the editor rebuilds its group objects at every tab change' : ''}`, async () => { + // Arrange + const harness = await workspaceHarness() + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + harness.activateNewGroupLateAfterInsertion() + if (rebuildsGroupObjects) harness.replaceGroupObjectsOnTabChanges() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() - // Act - harness.receiveContext({ - type: 'node-history', - revision: lastView(harness)!.revision, - studyId: history.studyId, - }) - // A refusal ends the entry without a landing, so the wait ends on either result. - await waitUntil( - () => - lastView(harness)?.nodeHistoryPosition?.sequence === 2 || - logged.some( + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + // A refusal ends the entry without a landing, so the wait ends on either result. + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 || + logged.some( + (entry) => entry.surface === 'presentation:placement-refused', + ), + 'the Saved studies choice lands on the first stop or is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert: the editor's late report reached its live group model (the premise), and the open was not refused. + assert.equal(harness.lateGroupActivationsReported(), 1) + assert.deepEqual( + logged.filter( (entry) => entry.surface === 'presentation:placement-refused', ), - 'the Saved studies choice lands on the first stop or is refused', - READER_RESULT_BOUND_MS, - ) - - // Assert - assert.deepEqual( - logged.filter( - (entry) => entry.surface === 'presentation:placement-refused', - ), - [], - ) - await landsOn( - harness, - 2, - 'the Saved studies choice lands on the first stop', - ) - } finally { - await harness.close() - } -}) + [], + ) + await landsOn( + harness, + 2, + 'the Saved studies choice lands on the first stop', + ) + } finally { + await harness.close() + } + }) for (const entry of ['Open replay', 'Saved studies'] as const) { // Value: protects=the learner's entry into a test replay in VS Code gives region 2's composition: the Study pane closes, the Application pane and the recorded source stay, and the Learning summary shows in the chat's place, moved beside the chat once per profile; fails_when=the Study pane stays open with the replay in its Test replay section, the summary keeps its placeholder, or the move does not run; why_new=Cycle 0051 DS-A (AC1): a test replay entered the Study composition; seam=workspaceHarness (VS Code), openLearningReplay and the Study pane's node-history message, the fake vscode.moveViews; no_claim=the native placement, which N51 (a) checks From 896e421255b265bcc040df86a78ac1c415a2f080 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 16:40:21 +0900 Subject: [PATCH 28/31] fix: Cycle 0056 pt3: resolve CodeRabbit's findings on 4c9d0b58 (PR #138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: a copied Output › Codebook entry shows the home folder, Codebook's storage and the workspace folders as ~, and and has no control characters; other error text stays as the error gave it, so read the entry before sharing it (the old "holds no credentials" overclaimed). - studyWorkspaceHarness: every group copy that keeps a group's identity records its successor (nativeGroup's wrapper copies, the chat part's resync and replaceEditorGroup, except an "opened" replacement, which is another group). No current cell opens a group while a late report waits, so this changes no result; the H2-b test also runs in the wrapper mode. --- extension/README.md | 2 +- extension/src/tests/studyWorkspaceHarness.ts | 10 ++++++++-- ...tudyWorkspaceTestReplayComposition.test.ts | 19 +++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/extension/README.md b/extension/README.md index 4d10add1..38e7ae1d 100644 --- a/extension/README.md +++ b/extension/README.md @@ -100,7 +100,7 @@ The executed source shows in one **recorded-source tab**, named after the stop's The time slider marks the recording between its first and last event as unrecorded, with a tick at each stop whose recorded source verifies, under `Ticks mark recorded stops; source and debugger are not recorded between them.` (a test journal records when a stop paused, not when the test resumed). **Play** moves from event to event and waits at most 1.5 seconds for the next one, also across an unrecorded interval, which it neither pauses at nor asks about; each surface shows the selected event's own time. -Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused (and, for the focus guard, the name of the call that armed it and the comparisons that failed, or `Failed checks: unattributed (none of the compared parts differ)` when none of them differ), the editor groups before and after with the tab each group showed, the focused tab when the guard was armed against the active tab at the refusal, the changes the editor reported meanwhile (group changes, tab operations, view-state reports of Codebook's panes and the active text editor) and the reveals, disposals, editor commands and shown documents Codebook itself issued meanwhile; the sentence shown in the Study pane is unchanged. +Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it. In the entry your home folder, Codebook's storage and the workspace folders read `~`, `` and ``, and control characters are removed; any other text of an error stays as the error gave it, so read the entry before you share it. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused (and, for the focus guard, the name of the call that armed it and the comparisons that failed, or `Failed checks: unattributed (none of the compared parts differ)` when none of them differ), the editor groups before and after with the tab each group showed, the focused tab when the guard was armed against the active tab at the refusal, the changes the editor reported meanwhile (group changes, tab operations, view-state reports of Codebook's panes and the active text editor) and the reveals, disposals, editor commands and shown documents Codebook itself issued meanwhile; the sentence shown in the Study pane is unchanged. The bundled skill (since 1.0.48) routes a question about a test, or about library behavior a test exercises, to the `node-test` profile, and the chat explains each test stop in the chat. A value the chat asks for at a test stop is also read when it is a name the stopped file imports (`revalidatePath.mock.calls.length` of a mocked `next/cache`, for example), through the module Vitest binds it to, as the export its import declaration names: an alias reads the module's original export and a default import its default export, each from that declaration's own module, never from another module that happens to export the same name; a namespace import, a name the file does not import, and an import whose module the stop cannot see stay unavailable rather than guessed. A getter on the asked path (a Vitest mock's `mock`, for example) runs once, as it does when you expand it in the Variables view; a getter the debugger cannot expand, or expands to no single value, leaves only that value unavailable, never its `(...)` placeholder, and a local variable whose value cannot be read still takes priority over an import of the same name. A required value that still cannot be read no longer ends the run: the stop keeps its other values and names the missing one among its debugger limitations, and the `codebook_continue` that reached it (or your Continue in the Study pane) answers `EVIDENCE_UNAVAILABLE` naming it while the run stays paused there, so the chat, or you, can continue without it; the run's history then seals partial, with a reason that names the value and its stop, and the study reads partial although the test passed. A run that ends with an `error` before its first stop reached no stop and explains nothing (a refusal before the launch ran no test; a test that exited before that stop names its exit code or signal): the chat starts it again only where the published recovery of that error's code allows (`codebook://help/errors`), and it treats any text the error quotes, such as test or tool output, as data. Declared stops are reached in the order the test runs their lines: a later stop on a line the test ran before the previous stop is reached only if the test runs that line again after it (in a loop, for example); otherwise it is never reached, and the continuation then says `Owned Node test exited with code (or signal ) before declared stop .`, naming that stop, instead of only that the debug session ended; the node-test help tells the chat to choose stops in that order, and names the exit code or signal of a test that ends before a stop. A test that ends before its first declared stop says `Owned Node test exited with code (or signal ) before declared stop 1.`, then that the first declared stop is reached only when the test runs its line. That sentence comes only when the test ran on and did not reach the stop: a refusal before the test runs on (after you stopped the debug session yourself, for example) and a failure while a reached stop's values are captured keep their own words. The chat claims saved learning only when the run reads `studyOutcome` `demonstrated`, `nodeTest.learningCoverage` `complete` and an empty `nodeTest.captureMissing` (a run resource without those fields supports no such claim), says when a test did not pass and names its exit code or signal, and points you to **Save learning replay** and **Open replay**. When the connection is unavailable, it sends you to **Codebook: Set Up AI Chat**, and in a chat of VS Code's Copilot harness (the default for a new chat), which cannot reach Codebook's tools, to open a Local chat session. diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 8a9d3641..75545472 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1112,7 +1112,7 @@ export async function workspaceHarness( (typeof groups)[number] >() /** - * Records that a copy replaces a group object in the fake group model, as Cursor's extension host gives its groups new objects; every rebuild of the model goes through here. + * Records that a copy replaces a group object in the fake group model, as Cursor's extension host gives its groups new objects; every copy that keeps a group's identity goes through here (a replacement reported as an opened group is another group and records none). * @returns The copy, for the rebuilt list. * @example groups.map((group) => replacedBy(group, { ...group, tabs: [...group.tabs] })) */ @@ -1557,7 +1557,9 @@ export async function workspaceHarness( } // Cursor replaces surviving wrappers in the same event that announces an opened group. const retained = cursorGroupWrappers - ? groups.map((previous) => ({ ...previous, tabs: [...previous.tabs] })) + ? groups.map((previous) => + replacedBy(previous, { ...previous, tabs: [...previous.tabs] }), + ) : [] if (cursorGroupWrappers) { if (separateEditorPart) @@ -7090,6 +7092,8 @@ export async function workspaceHarness( replacement = { ...previous, tabs: [...previous.tabs] } // The unchanged empty chat part is not a new native group; explicit lifecycle events cannot inherit it. if (change === 'move') replacement.viewColumn++ + // An opened replacement is another group; the others keep the group's identity. + if (change !== 'opened') replacedBy(previous, replacement) groups[groups.indexOf(previous)] = replacement separateEditorPart!.group = replacement changedGroups({ @@ -7813,6 +7817,8 @@ export async function workspaceHarness( const previous = groups.find((group) => group.viewColumn === column) assert.ok(previous) const replacement = { ...previous, tabs: [...previous.tabs] } + // An opened replacement is another group; the others keep the group's identity. + if (change !== 'opened') replacedBy(previous, replacement) changeTabs?.(replacement.tabs) groups[groups.indexOf(previous)] = replacement if (activeTabGroup === previous) activeTabGroup = replacement diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 75266297..8c56ada8 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -325,9 +325,19 @@ test('a test replay chosen while the Application is alone in its group opens wit } }) -// Value: protects=N55-F4 H2-b: a test replay opens although the editor reports the Application's new group active only after newGroupAbove answered; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateNewGroupLateAfterInsertion, the diagnostics log, and in the second cell replaceGroupObjectsOnTabChanges (Cursor's extension host gives groups new objects at tab changes; the late report follows the inserted group through them, PR #138 review); no_claim=that Cursor reports it this way (N56 step 1 checks natively) -for (const rebuildsGroupObjects of [false, true]) - test(`a test replay whose Application group the editor activates late opens without a placement refusal${rebuildsGroupObjects ? ', also when the editor rebuilds its group objects at every tab change' : ''}`, async () => { +// Value: protects=N55-F4 H2-b: a test replay opens although the editor reports the Application's new group active only after newGroupAbove answered; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateNewGroupLateAfterInsertion, the diagnostics log, and in the other cells replaceGroupObjectsOnTabChanges or useCursorGroupWrappers (Cursor's extension host gives groups new objects at tab changes and when it opens a group; the late report follows the inserted group through them, PR #138 review); no_claim=that Cursor reports it this way (N56 step 1 checks natively) +for (const [rebuild, shown] of [ + ['none', ''], + [ + 'tab changes', + ', also when the editor rebuilds its group objects at every tab change', + ], + [ + 'opened groups', + ', also when the editor gives its groups new wrappers whenever it opens a group', + ], +] as const) + test(`a test replay whose Application group the editor activates late opens without a placement refusal${shown}`, async () => { // Arrange const harness = await workspaceHarness() const logged: CodebookDiagnostic[] = [] @@ -336,7 +346,8 @@ for (const rebuildsGroupObjects of [false, true]) show: () => undefined, } harness.activateNewGroupLateAfterInsertion() - if (rebuildsGroupObjects) harness.replaceGroupObjectsOnTabChanges() + if (rebuild === 'tab changes') harness.replaceGroupObjectsOnTabChanges() + if (rebuild === 'opened groups') harness.useCursorGroupWrappers() try { const history = await saveTwoStopNodeHistory( harness.service.nodeHistoryStore!, From f5a509a5599b299fe17d6fef1c0c9427e121bef7 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 17:11:37 +0900 Subject: [PATCH 29/31] fix: Cycle 0056 pt3: resolve CodeRabbit's outside-diff findings on 896e4212 (PR #138) - studyWorkspaceHarness: resyncEditorGroups('replace') reports each group closed and its copy opened, so it records no successor; a late group report is dropped there instead of reaching the opened group. New harness fidelity test (made after a rewrap, dropped after a replace): red without the fix (replace case reported 1, expected 0). lateGroupReportsWaiting lets the test wait on the report instead of a timer. - README: the Wiki folder watch refreshes the rows one second after the last change on disk (not "within a second"). - README and CHANGELOG: the view hands the editor its message with its rows; the same-frame change is stated for VS Code, where N56 checked it. --- CHANGELOG.md | 2 +- extension/README.md | 4 +-- extension/src/tests/studyWorkspaceHarness.ts | 18 +++++++--- ...tudyWorkspaceTestReplayComposition.test.ts | 33 +++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c5f13e..8d526266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a **The LLM Wiki view.** -- **Message and rows change together**: at a run's start and end the message above the tree changes in the same frame as the rows. +- **Message and rows change together**: the view hands the editor the message above the tree with its rows, so in VS Code, at a run's start and end, the message changes in the same frame as the rows. - **The view follows its folder**: deleting or moving the Wiki folder, or any folder above it inside your project (for example `rm -rf .codebook`), brings back the welcome without **Refresh**. - **Manage Workspace Trust opens the editor's trust editor** in both editors through `workbench.trust.manage`; if the editor lacks that command, the view points you to “Workspaces: Manage Workspace Trust” in the Command Palette, and if the command fails it says so and records the cause, with your folders' paths shortened, in **Output › Codebook**. diff --git a/extension/README.md b/extension/README.md index 38e7ae1d..269acfbc 100644 --- a/extension/README.md +++ b/extension/README.md @@ -247,7 +247,7 @@ For tools: `GET /api/update-status` answers the current state as JSON (` The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site of the opened project stands and the next step; it sits below the **Repository Wiki** view, which lists repository articles, not the generated site. Its rows come and go with the facts they show, in a fixed order, and the tooltip of a row has its full text: - With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. If the editor has no Manage Workspace Trust command the view points to **Workspaces: Manage Workspace Trust** in the Command Palette (also when the editor's command list cannot be read); if the command fails, the view says it could not open, names that command, and the cause is in **Output › Codebook**. -- The view follows its Wiki folder on disk: creating the folder, or deleting or moving it or any folder above it inside your project (for example `rm -rf .codebook`), refreshes the rows within a second, and a deleted Wiki brings back the welcome without **Refresh**. A Wiki folder outside the project is watched from its own parent folder only, so deleting that parent shows after **Refresh** or the next time the view is shown (Codebook never watches your home folder or a drive root). +- The view follows its Wiki folder on disk: creating the folder, or deleting or moving it or any folder above it inside your project (for example `rm -rf .codebook`), refreshes the rows one second after the last change on disk (a burst of changes is one read), and a deleted Wiki brings back the welcome without **Refresh**. A Wiki folder outside the project is watched from its own parent folder only, so deleting that parent shows after **Refresh** or the next time the view is shown (Codebook never watches your home folder or a drive root). - In a folder with no Wiki and no run the view shows a welcome with a **Generate LLM Wiki** button, what runs it in your editor (Copilot Chat in VS Code, `cursor-agent` in Cursor) and the output folder with a **Change location** link (`The Wiki is written to .codebook/wiki unless the setting codebook.wikiOutputDirectory names another folder.`). A click on the **Output** row opens the same setting. - **` Wiki`** is the published Wiki. A click opens it in the browser. Its description is the freshness label of the reader page (`Wiki source current`, `Source changed · Update available`, `Wiki freshness unknown: …`, or `Checking freshness…` while the check runs), and its tooltip names the generation: `Generated from on `, `Generated on (no Git revision)` in a folder without Git, or `Generated from ` when the date cannot be read; when the Wiki records no Git revision, the tooltip also says in full that added files are not checked, which the description may cut off in a narrow sidebar. **Update LLM Wiki** is its action while no run shows. The row stays while a run works, so you can read the Wiki during an update. - **`Generating…` or `Updating…`** is this window's run. In VS Code it reads `Waiting for the chat to start` and then `In chat · min of `, with the actions **Stop Watching** (an eye-closed icon), **Open Chat** (the chat that waits for an approval or never started is one click away) and **Show Output**; the message above the tree says that Stop watching does not stop the chat. A failed generation's row offers **Try Again** (a restart icon). Inline action names are in Title Case (**Stop Watching**, **Change Location**, **Open Chat**, **Open Folder**, **Show Output**, **Try Again**, **Copy Recovery Request**); **Cancel** keeps the stop icon because it stops the agent. In Cursor it reads `cursor-agent · min of ` with **Cancel** and **Show Output**. The description is read again every 30 seconds. If the start time cannot be read, the row shows no minutes (`In chat` or `cursor-agent`) rather than a wrong number. @@ -258,7 +258,7 @@ The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site - **`Output: `** shows the resolved folder with **Change Location** (the `codebook.wikiOutputDirectory` setting) whenever another row shows. A folder outside the project shows its absolute path. - A read that fails shows `Could not read the LLM Wiki state` with `Refresh to retry`; a click opens Output › Codebook, where the cause is written. -When the view's state changes, for example when a run starts or ends, the message above the tree and the rows change together in the same frame, not one before the other. +When the view's state changes, for example when a run starts or ends, Codebook hands the editor the message above the tree with the rows it belongs to, so in VS Code the message and the rows change in the same frame, not one before the other. The view reads its state again after a change of this window's run and once this window has removed or turned into a reservation the run's marker (so `Waiting for chat` shows also without a file event), after a change of the Wiki's manifest or of the three marker files beside the Wiki folder (one second after the last change, so a burst of changes is one read), after a change of `codebook.wikiOutputDirectory` or of workspace trust, when the view is shown, every 30 seconds while the view is shown and a run row shows (a marker whose window died leaves no file event; the poll goes on across the error row of a read that failed after a run row showed), and with the **Refresh** action in its title bar. A collapsed or hidden view runs no poll and no freshness check, not even one that was waiting when it was hidden, and reads once when it is shown again. Inside the project the watch stands on the project folder from the start and stays there, also in a fresh project whose Wiki folder's parent does not exist yet, so creating the Wiki folder or deleting or renaming it or a folder above it is seen (never the home folder or a drive root: when the project folder is one of those, the watch stands on the nearest existing folder below it). An output folder outside the project is watched from its own parent only when that exists; the home folder and a file-system root are never watched. The editor reports nothing for the watched folder itself, so deleting or renaming the project folder (or a folder above it) shows after **Refresh** or the next time the view is shown; the same holds for the nearest existing folder that is watched when the project folder is your home folder or a root. If the editor refuses the watch, or there is no folder to watch, the view refreshes when it is shown and with **Refresh** only, the **Output** row's tooltip says so and Output › Codebook records the cause once. A failure of a read or of the freshness check is recorded once while it keeps happening (a message that differs only in its numbers counts as the same failure), and again when a different one comes or after a good read or check. The freshness label is read at each refresh, so a source edit shows as `Source changed` when the view is next shown or refreshed, not at the save. A refresh with nothing new changes nothing the screen reader announces and never moves the focus. Every action is also in the row's context menu (Shift+F10). **Codebook: Refresh LLM Wiki**, **Codebook: Copy LLM Wiki Recovery Request** and **Codebook: Reveal LLM Wiki Folder** are listed in the Command Palette only in an editor that shows the view. Only the first workspace folder is read. The 55 minutes of a generation count wall-clock time, so time asleep counts toward them. diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 75545472..1825aa35 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -6090,6 +6090,12 @@ export async function workspaceHarness( * @example harness.lateGroupActivationsReported() // => 1 after activateNewGroupLateAfterInsertion and one Application group insertion */ lateGroupActivationsReported: () => reportedLateGroupActivations, + /** + * Counts the late group reports the fake editor has queued and not yet made, reported or dropped. + * @returns How many late reports still wait. + * @example harness.lateGroupReportsWaiting() // => 0 once the editor's late report has run + */ + lateGroupReportsWaiting: () => pendingLateGroupActivations.length, activateNewGroupLateAfterInsertion(delayMs?: number): void { activatesNewGroupLateAfterInsertion = true newGroupActivationDelayMs = delayMs @@ -7745,8 +7751,8 @@ export async function workspaceHarness( groups.splice( 0, groups.length, - ...groups.map((group) => - replacedBy(group, { + ...groups.map((group) => { + const replacement = { ...group, tabs: group.tabs.map((tab) => { const refreshed = { @@ -7757,8 +7763,12 @@ export async function workspaceHarness( refreshedTabs.set(tab, refreshed) return refreshed }), - }), - ), + } + // A replace reports each group closed and its copy opened: another group, which a late report must not reach. + return change === 'replace' + ? replacement + : replacedBy(group, replacement) + }), ) // A full tab-model resync changes API objects, never the native webview handle that later reveal and dispose operate on. for (const current of panels) { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 8c56ada8..94f63760 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -325,6 +325,39 @@ test('a test replay chosen while the Application is alone in its group opens wit } }) +for (const [rewrap, reported] of [ + [undefined, 1], + ['replace', 0], +] as const) + // Value: protects=the fidelity of the workspace harness's late group reports, on which the H2-b cells rest: a late report follows its group through a rebuild that keeps the group (new wrappers) and is dropped when the editor replaced the group by an opened one; fails_when=the fake editor carries the report to a group it reported as opened in place of the inserted one (closed + opened), so a cell can pass on an activation the editor never made; why_new=PR #138 CodeRabbit review on 896e4212; seam=workspaceHarness, activateNewGroupLateAfterInsertion, the fake editor's newGroupAbove, resyncEditorGroups, lateGroupActivationsReported and lateGroupReportsWaiting; no_claim=any editor's own behaviour + test(`the fake editor ${reported ? 'makes' : 'drops'} a late group report when it ${rewrap ? 'replaced the group by an opened one' : 'gave the group new wrappers'} before the report`, async () => { + // Arrange + const harness = await workspaceHarness() + harness.activateNewGroupLateAfterInsertion(LATE_GROUP_ACTIVATION_WAIT_MS) + try { + // The command acts on the first group, as the preparation's own call does. + await harness.editor.commands.executeCommand( + 'workbench.action.focusFirstEditorGroup', + ) + await harness.editor.commands.executeCommand( + 'workbench.action.newGroupAbove', + ) + + // Act: the editor rebuilds its groups while the late report waits. + harness.resyncEditorGroups(rewrap) + await waitUntil( + () => harness.lateGroupReportsWaiting() === 0, + 'the late group report has run', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(harness.lateGroupActivationsReported(), reported) + } finally { + await harness.close() + } + }) + // Value: protects=N55-F4 H2-b: a test replay opens although the editor reports the Application's new group active only after newGroupAbove answered; fails_when=the entry arms a guard while the editor's late report of Codebook's own placement is still on its way, reads the report as the learner's focus change and refuses with "Native pane, group or focus changed while presentation waited"; why_new=Cycle 0056 task 17 stage 2 (N55-F4 refusal 1 is a rare race, hardened against both hypotheses); seam=workspaceHarness with activateNewGroupLateAfterInsertion, the diagnostics log, and in the other cells replaceGroupObjectsOnTabChanges or useCursorGroupWrappers (Cursor's extension host gives groups new objects at tab changes and when it opens a group; the late report follows the inserted group through them, PR #138 review); no_claim=that Cursor reports it this way (N56 step 1 checks natively) for (const [rebuild, shown] of [ ['none', ''], From 79a7e7820c1df16b871e286b88b0248a03abd35d Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 17:18:08 +0900 Subject: [PATCH 30/31] fix: Cycle 0056 pt3: local CodeRabbit CLI findings on f5a509a5 - The H2-b cells wait until the editor's late group report has run before they read its count: the report may come after the landing (next layout read or its timer). - studyWorkspaceHarness: lateGroupActivationsReported and lateGroupReportsWaiting sit before activateNewGroupLateAfterInsertion's JSDoc, so that method keeps its documentation. --- extension/src/tests/studyWorkspaceHarness.ts | 12 ++++++------ .../studyWorkspaceTestReplayComposition.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 1825aa35..3c3ffe94 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -6078,12 +6078,6 @@ export async function workspaceHarness( activateApplicationGroupLateAfterReveal(): void { activatesApplicationGroupLateAfterReveal = true }, - /** - * Makes the fake editor report the group `workbench.action.newGroupAbove` made active only after the command answered, as Cursor activates a new group late (N55-F4 hypothesis H2-b, row 650). - * @param delayMs - How long after the command the report comes; omitted for the editor's usual short delay. - * @example harness.activateNewGroupLateAfterInsertion() - * @example harness.activateNewGroupLateAfterInsertion(600) // later than the preparation's wait - */ /** * Counts the late group activations the fake editor reported to a group of its live model; a report whose group is no longer in the model is dropped and not counted. * @returns How many late activations reached a live group so far. @@ -6096,6 +6090,12 @@ export async function workspaceHarness( * @example harness.lateGroupReportsWaiting() // => 0 once the editor's late report has run */ lateGroupReportsWaiting: () => pendingLateGroupActivations.length, + /** + * Makes the fake editor report the group `workbench.action.newGroupAbove` made active only after the command answered, as Cursor activates a new group late (N55-F4 hypothesis H2-b, row 650). + * @param delayMs - How long after the command the report comes; omitted for the editor's usual short delay. + * @example harness.activateNewGroupLateAfterInsertion() + * @example harness.activateNewGroupLateAfterInsertion(600) // later than the preparation's wait + */ activateNewGroupLateAfterInsertion(delayMs?: number): void { activatesNewGroupLateAfterInsertion = true newGroupActivationDelayMs = delayMs diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 94f63760..f76247d2 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -405,6 +405,13 @@ for (const [rebuild, shown] of [ READER_RESULT_BOUND_MS, ) + // The editor's late report may come after the landing: it runs at the next layout read or its timer. + await waitUntil( + () => harness.lateGroupReportsWaiting() === 0, + 'the editor’s late group report has run', + READER_RESULT_BOUND_MS, + ) + // Assert: the editor's late report reached its live group model (the premise), and the open was not refused. assert.equal(harness.lateGroupActivationsReported(), 1) assert.deepEqual( From bfe62b320536b6140eeaf8d56c2bb88386f355f9 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Tue, 27 Oct 2026 17:21:02 +0900 Subject: [PATCH 31/31] docs: Cycle 0056 pt3: the late-report claims and N56's scope say only what was observed - README and CHANGELOG: an editor that reports the Application's new group or its activation a moment late is the cause N55-F4's refusals pointed to, reproduced in tests; Cursor's own report order was not observed natively. - CHANGELOG: each N56 candidate was packaged from its commit, the QA clone carried QA-owned edits, and N56 did not run again on the released source. --- CHANGELOG.md | 4 ++-- extension/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d526266..01d2b4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## [0.2.49.0] - 2026-10-27 (development candidate) -Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, last of its three pull requests: a saved test replay opens in Cursor when it is chosen, the Saved studies list and the Learning summary say what is true around **Codebook: Close Study Workspace**, and the LLM Wiki view follows its folder, trusts through the editor's own command and changes its message with its rows. The native trial N56 checked part of this in the real editors, on this part's development candidates (`02acefaa`, `7ff3ab57`, `932cdae3`), not on the released build: VS Code 1.142.0 and Cursor 3.27.12 on macOS 26 (a virtual machine), with the development target `nextjs-dashboard`. It covered the test replay's opens and the Saved studies list after Close in Cursor, the trust command in both editors, and the view's message and rows and a deleted Wiki folder in VS Code; the other changes are covered by tests only. Each step's host, candidate and result, and the hashes of its recordings (kept private), are in [Issue #135's comments](https://github.com/laststance/codebook/issues/135#issuecomment-6431339240). +Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, last of its three pull requests: a saved test replay opens in Cursor when it is chosen, the Saved studies list and the Learning summary say what is true around **Codebook: Close Study Workspace**, and the LLM Wiki view follows its folder, trusts through the editor's own command and changes its message with its rows. The native trial N56 checked part of this in the real editors, on this part's development candidates (`02acefaa`, `7ff3ab57`, `932cdae3`), not on the released build: VS Code 1.142.0 and Cursor 3.27.12 on macOS 26 (a virtual machine), with the development target `nextjs-dashboard`. It covered the test replay's opens and the Saved studies list after Close in Cursor, the trust command in both editors, and the view's message and rows and a deleted Wiki folder in VS Code; the other changes are covered by tests only. Each candidate was packaged from its own commit; the target's QA clone carried QA-owned edits that the Issue names, and the designated checkout none. N56 did not run again on the released source, which adds the review fixes made after `932cdae3`. Each step's host, candidate and result, and the hashes of its recordings (kept private), are in [Issue #135's comments](https://github.com/laststance/codebook/issues/135#issuecomment-6431339240). **Saved test replays.** -- **A saved test replay opens in Cursor when you choose it**: choosing it from a Study pane that stands in a group with your own tabs, or while Cursor reports the Application's new group a moment late, no longer refuses with `Native pane, group or focus changed while presentation waited`; in N56, five opens in a row opened without a refusal. +- **A saved test replay opens in Cursor when you choose it**: choosing it from a Study pane that stands in a group with your own tabs no longer refuses with `Native pane, group or focus changed while presentation waited`, and neither does an editor that reports the Application's new group, or its activation, a moment late (the two causes N55-F4's refusals pointed to, reproduced in tests; Cursor's own report order was not observed); in N56, five opens in a row in Cursor opened without a refusal. - **A refused or failed open never holds up your next choice**: its notice is shown and recorded at once, and your next Saved studies choice or **Open replay** is carried out; a choice the Learning summary sends while the Study workspace is closed, closing or no longer showing a replay is not carried out, and you are told so in a notice that starts `Codebook did not carry out your choice:` and says what to do next, with one `choice:dropped` entry in **Output › Codebook**. - **After Close Study Workspace the Saved studies list names no study** (it reads `Choose a saved study`), so choosing any entry that can be opened opens it, the one the closed reader showed included; **Codebook: Open Study Workspace** still shows that replay again. An entry that cannot be opened and that you chose before the Close stays chosen with its delete control. - **Opening another replay after Close never shows the closed one first**: the Learning summary goes from its opening state straight to the replay you chose. diff --git a/extension/README.md b/extension/README.md index 269acfbc..ec08f2f0 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A test replay also opens when the editor (Cursor included) reports the Application's new group, or its activation, a moment late; a change the editor reports later still refuses the open with `Native pane, group or focus changed while presentation waited` and the `presentation:placement-refused` entry described below. **Codebook: Close Study Workspace** ends without an error when the editor already closed the panes itself, and a Close made while an open still waits leaves the workspace closed. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry that can be opened opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. An entry that cannot be opened (below) and that you chose before the Close stays chosen, with its delete control. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed: there is no `Captured-overview` tab beside the replay's recorded-source tab. The captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source. A captured-source tab of another study stays, and so does any captured-source tab you open yourself after leaving the browser replay. A tab you close by hand, or move to another editor group, after leaving the browser replay is yours: it stays when you reopen it at the same address. A close that the editor refuses is told and retried at the next stop. The open then shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load. The entry is written at once: a failure notification you leave unanswered never holds up your next Saved studies choice or Open replay, which opens or is refused with its own entry. A Study pane that stands in a group with your own tabs closes before the replay arranges its panes, so choosing a test replay there is not refused over the editor's report of your next tab. A test replay also opens when the editor reports the Application's new group, or its activation, a moment after the command that made it (tests reproduce this; whether Cursor reports it that way was not observed natively); a change the editor reports later still refuses the open with `Native pane, group or focus changed while presentation waited` and the `presentation:placement-refused` entry described below. **Codebook: Close Study Workspace** ends without an error when the editor already closed the panes itself, and a Close made while an open still waits leaves the workspace closed. A failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. After **Codebook: Close Study Workspace**, the Saved studies list names no study (it reads `Choose a saved study`), so choosing any entry that can be opened opens it, the replay or recording the closed reader showed included; **Codebook: Open Study Workspace** shows that replay again and the list names it. An entry that cannot be opened (below) and that you chose before the Close stays chosen, with its delete control. Opening another test replay from a closed reader (Open replay or a Saved studies choice) never draws the replay the closed reader showed in the Learning summary while the chosen one opens: the summary shows the opening state, then the chosen replay. An open of another replay that is refused leaves the replay the closed workspace showed selected, so **Codebook: Open Study Workspace** still shows it. A choice you make in the Learning summary that Codebook cannot carry out is not lost silently: the notice names the reason and what to do next. The notice starts `Codebook did not carry out your choice:` and ends with one of three reasons. `the Study workspace is closed. To continue, run “Codebook: Open Study Workspace”, then make your choice again.` `the Study workspace is closing. When it has closed, run “Codebook: Open Study Workspace”, then make your choice again.` `the Learning summary is not showing a replay. Open a replay from Saved studies, then make your choice again.` (the workspace is open but shows the Live arrangement or the Study pane). Every dropped choice raises its notice again; the editor shows one notice for identical texts. **Output › Codebook** records one `choice:dropped` entry for every dropped choice, naming the study when the choice carries one.