diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 5fe26cac94..eb7d9ec321 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -102,6 +102,45 @@ describe('Maka Pi TUI transcript', () => { } }); + test('traces quotes on the durable user entry', () => { + const state = createMakaPiTranscriptState(); + const excerpt = { + text: 'a large pasted excerpt', + label: 'earlier turn', + sourceTurnId: 'turn-0', + }; + replaceTranscriptWithStoredMessages(state, [ + // A quote-only submit stores no text: without the trace the sent + // context would leave no row at all (#5109 review). + { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: '', + quotes: [excerpt, { ...excerpt, text: 'second excerpt' }], + }, + { + type: 'user', + id: 'message-2', + turnId: 'turn-1', + ts: 2, + text: 'with words', + quotes: [excerpt], + }, + { type: 'user', id: 'message-3', turnId: 'turn-1', ts: 3, text: 'plain' }, + ] as StoredMessage[]); + const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); + assert.match(rendered, /· 2 quotes/); + assert.match(rendered, /· 1 quote/); + assert.match(rendered, /with words/); + assert.equal( + (rendered.match(/· \d+ quotes?/g) ?? []).length, + 2, + 'messages without quotes render no hint', + ); + }); + test('renders stored legacy Automation prompts as read-only provenance', () => { const state = createMakaPiTranscriptState(); replaceTranscriptWithStoredMessages(state, [ diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index ac42d5b600..c638a8efae 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -61,9 +61,11 @@ import type { MakaSessionSwitchResult, MakaSubmitMessageOptions, MakaTranscriptReplacementReason, + MakaRetractedMessages, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; +import type { QuoteRef } from '@maka/core/events'; import { MakaSessionCatalogIncompleteError, skillInvocationBlockedMessage, @@ -8307,9 +8309,15 @@ Slug openai-work ]); }); - test('does not retract the switched-to queue when enqueues settle after a switch', async () => { + test('stages a rewound turn quotes into the replacement submit', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new QuotedRewindDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [ + storedUserMessage('user-1', 'turn-1', 'first question'), + storedAssistantMessage('assistant-1', 'turn-1', 'first answer'), + ], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8320,29 +8328,28 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - - // Park a second enqueue inside the driver, then Shift+Left: the runner must - // wait for that enqueue, notice the session moved underneath it, and drop - // the retraction *before* calling retractQueued — otherwise the Host call - // retracts the switched-to session's queue and the mismatch fence then - // discards those messages silently (#5109 review). - driver.enqueueGate = deferred(); - terminal.input('second queued'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); terminal.input('\r'); - terminal.input('\x1b[1;2D'); // Shift+Left: waits on the parked enqueue - driver.switchSession('session-other'); - driver.enqueueGate.resolve(); - await waitFor(() => driver.queuedRows.length === 2); - await delay(30); - assert.equal(driver.retractCalls, 0, 'the moved-past retraction must not run'); + await waitFor(() => driver.rewound.length === 1); + // The restored quotes are visible while staging is live: the rewind + // notice names them and the status line carries a quotes: segment. + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quoted context')); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); + // The replacement submit carries the staged QuoteRefs verbatim. + terminal.input('answer with this context'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + // Staging is consumed by the submit it rode on. + terminal.input('plain follow-up'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.equal(driver.submittedQuotes[1], undefined); exitMaka(terminal); await Promise.race([ @@ -8353,9 +8360,12 @@ Slug openai-work ]); }); - test('keeps the interrupt stop bound to the session it was asked for', async () => { + test('restores a failed quote submit only to its originating session', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', 'first question')], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8366,28 +8376,27 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + terminal.input('resend this'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); - // Ctrl+C interrupts the running turn; the pre-stop retraction is held on - // a gate while the user switches sessions. The switch owns stopping the - // old turn — the fenced interrupt must not stop the session it landed on - // (#5109 review). - driver.retractGate = deferred(); - terminal.input('\x1b'); // double Escape arms and fires the interrupt - terminal.input('\x1b'); - await waitFor(() => driver.retractCalls === 1); + // A Session switch lands while the admission is still pending. driver.switchSession('session-other'); - driver.retractGate.resolve(); - await waitFor(() => driver.retractedTexts.length === 1); - await delay(30); - assert.equal(driver.stopCalls, 0, 'the fenced stop must not hit the switched-to session'); + driver.hold(new Error('admission outcome unknown')); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('admission outcome unknown'), + ); - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); + // The next message in the new Session must not carry the old quotes. + terminal.input('unrelated follow-up'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.equal(driver.submittedQuotes[1], undefined); exitMaka(terminal); await Promise.race([ @@ -8398,9 +8407,12 @@ Slug openai-work ]); }); - test('serializes a mid-turn session switch behind an in-flight retraction', async () => { + test('restages a failed quote submit back onto the same session', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', 'first question')], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8411,40 +8423,29 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - - // The Shift+Left retraction is held on the Host call; a mid-turn `/session` - // arriving while it is in flight must wait for it — the retracted text - // lands in the session it was asked for before the driver re-keys (#5109 - // review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - terminal.input('/session session-other'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); terminal.input('\r'); - driver.retractGate.resolve(); - await delay(200); - await waitFor(() => driver.retractedTexts.length === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + terminal.input('resend this'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); - const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); - const switchDone = driver.eventLog.findIndex((entry) => entry.startsWith('switch:')); - assert.ok(retractDone !== -1, 'the retraction completed'); - assert.ok( - switchDone === -1 || retractDone < switchDone, - 'the switch waits behind the retraction: ' + driver.eventLog.join(','), - ); - assert.equal( - driver.retractedTexts.at(-1), - 'queued resend', - 'the retraction lands with the payload it was asked for', + // The admission fails with the Session unchanged: the quotes return to + // the staging for the retry the feature promises (#5109 review). + driver.hold(new Error('admission outcome unknown')); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('admission outcome unknown'), ); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('retry then'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); exitMaka(terminal); await Promise.race([ @@ -8455,9 +8456,12 @@ Slug openai-work ]); }); - test('refuses a second mid-turn /session while the first waits for the retraction drain', async () => { + test('restages quotes when a submit resolves without a receipt (outcome unknown)', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new UnknownOutcomeSubmitDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', 'first question')], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8468,37 +8472,27 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - - // Hold the retraction in flight, then issue two mid-turn `/session` - // commands. The first parks inside the retraction drain before it sets - // `detaching`, so the second must not pass the same guard and re-key the - // driver concurrently with the first (#5265 review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - terminal.input('/session session-a'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); terminal.input('\r'); - await delay(50); // the first switch is parked inside the retraction drain - terminal.input('/session session-b'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + terminal.input('resend this'); terminal.input('\r'); - await delay(50); - driver.retractGate.resolve(); - await delay(50); - const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); - assert.equal( - switchStarts.length, - 1, - 'the second mid-turn switch must be refused while the first holds the detach: ' + - driver.eventLog.join(','), - ); + await waitFor(() => driver.submittedQuotes.length === 1); - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); + // The dispatch consumed the staging and the Host then resolved without a + // receipt: the quotes must come back instead of vanishing silently, with + // a notice naming the uncertainty (#5109 review). + driver.resolveUnknown(); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Submit outcome unknown')); + + terminal.input('retry then'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); exitMaka(terminal); await Promise.race([ @@ -8509,9 +8503,12 @@ Slug openai-work ]); }); - test('refuses an idle /session while a mid-turn detach is still draining its retraction', async () => { + test('a newer rewind displaces an in-flight quote submit restage', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new PerRewindQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', 'first question')], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8522,37 +8519,41 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + terminal.input('resend this'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); - // The mid-turn detach parks inside the retraction drain; the turn ends - // while it waits, so an idle `/session` is no longer busy-gated. It must - // still wait for the in-flight detach instead of re-keying the driver - // concurrently (#5265 review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - terminal.input('/session session-a'); + // A second rewind lands while the first submit's admission is still in + // flight: its own quotes are the new staging (#5109 review). The picker + // opens through runControl's async activity acquire, so wait for the + // driver call before selecting — scrollback still shows the first + // picker's frame, and text alone cannot tell the two openings apart. + terminal.input('/rewind'); terminal.input('\r'); - await delay(50); // the detach is parked inside the retraction drain - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/session session-b'); + await waitFor(() => driver.pickerOpens === 2); terminal.input('\r'); - await delay(50); - driver.retractGate.resolve(); - await delay(50); - const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); - assert.equal( - switchStarts.length, - 1, - 'the idle switch must be refused while the detach still holds the driver: ' + - driver.eventLog.join(','), + await waitFor(() => driver.rewound.length === 2); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // The stale admission fails now: its restage must not overwrite the + // newer rewind's staging. + driver.hold(new Error('admission outcome unknown')); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('admission outcome unknown'), ); + terminal.input('follow-up'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'excerpt from rewind 2', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + exitMaka(terminal); await Promise.race([ run, @@ -8562,11 +8563,12 @@ Slug openai-work ]); }); - test('refuses an idle /side while a mid-turn detach is still draining its retraction', async () => { + test('lets a quote-only rewind reach the submit path unchanged', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingSideConversationDriver([ - { turnId: 'turn-1', label: 'first question' }, - ]); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', 'first question')], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8577,40 +8579,30 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - - // The mid-turn detach parks inside the retraction drain; the turn ends - // while it waits, so an idle `/side` is no longer busy-gated. Its re-key - // must be refused instead of racing the in-flight detach's own re-key - // (#5265 review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - terminal.input('/session session-a'); + // The rewound prompt is empty and the quotes stage: they alone are the + // replacement content, so Enter with nothing typed must submit them. + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); terminal.input('\r'); - await delay(50); // the detach is parked inside the retraction drain - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/side'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); terminal.input('\r'); - await delay(50); - driver.retractGate.resolve(); - await delay(50); - assert.ok( - !driver.eventLog.some((entry) => entry.startsWith('open-start:')), - 'the idle side open must be refused while the detach still holds the driver: ' + - driver.eventLog.join(','), - ); - const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); - assert.equal( - switchStarts.length, - 1, - 'the detach must complete exactly once: ' + driver.eventLog.join(','), + await waitFor(() => driver.submittedQuotes.length === 1); + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + // After the explicit clear an empty draft is truly empty: no submit. + // The submit consumed the staging, so the clear reports the truth — + // there is nothing left to discard (#5109 review). + terminal.input('/quotes clear'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('No restored quotes are staged'), ); + terminal.input('\r'); + await delay(200); + assert.equal(driver.submittedQuotes.length, 1); exitMaka(terminal); await Promise.race([ @@ -8621,9 +8613,12 @@ Slug openai-work ]); }); - test('refuses /new while a mid-turn detach is still draining its retraction', async () => { + test('traces the quote count on the live transient row of a quote-only submit', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', 'first question')], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8634,39 +8629,20 @@ Slug openai-work terminal, }); - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/rewind'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - - // `/new` must stay reachable while a Turn runs (#3210 review), so the end - // of the Turn does not gate it either: while the mid-turn detach is still - // parked inside the retraction drain, startNewSession's re-key must be - // refused instead of racing the detach's own re-key (#5265 review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - terminal.input('/session session-a'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); terminal.input('\r'); - await delay(50); // the detach is parked inside the retraction drain - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/new'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + // A quote-only submit stores no text: until the Host echoes the message + // back, the live transient row is the only copy of the sent context. It + // must carry the quote trace instead of rendering an empty prompt + // (#5265 review). terminal.input('\r'); - await delay(50); - driver.retractGate.resolve(); - await delay(50); - assert.equal( - driver.startNewSessionCalls, - 0, - '/new must be refused while the detach still holds the driver: ' + driver.eventLog.join(','), - ); - const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); - assert.equal( - switchStarts.length, - 1, - 'the detach must complete exactly once: ' + driver.eventLog.join(','), + await waitFor(() => driver.submittedQuotes.length === 1); + await waitFor( + () => plainTerminalOutput(terminal.output()).includes('· 1 quote'), + 'the live transient row names the quotes it rode in on', ); exitMaka(terminal); @@ -8678,10 +8654,15 @@ Slug openai-work ]); }); - test('serializes an idle /session switch behind a retraction started during its activity wait', async () => { + test('discards staged quotes only through the explicit /quotes clear', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); - const activities = new SessionActivityRegistry(); + const driver = new QuotedRewindDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [ + storedUserMessage('user-1', 'turn-1', 'first question'), + storedAssistantMessage('assistant-1', 'turn-1', 'first answer'), + ], + ); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8690,63 +8671,239 @@ Slug openai-work connectionSlug: 'claude-subscription', permissionMode: 'ask', terminal, - turnActivity: createTestTurnActivity(activities), }); + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => driver.rewound.length === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // Ctrl+C clears the refilled draft so /quotes is not appended to it. + terminal.input('\x03'); + // Bare /quotes lists what is staged, including the quote body. + terminal.input('/quotes'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('a large pasted excerpt')); + + // The explicit clear drops the staging; the next submit carries nothing. + terminal.input('/quotes clear'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Restored quotes discarded'), + ); + terminal.input('plain'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + assert.equal(driver.submittedQuotes[0], undefined); + + // And bare /quotes on an empty staging says so. + terminal.input('/quotes'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('No restored quotes are staged'), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('restages quotes when the Host blocks the replacement submit', async () => { + const terminal = new FakeTerminal(); + const driver = new BlockedQuotedRewindDriver( + { + loaded: [], + failed: [{ request: 'typo', reason: 'not_found' }], + receipts: [], + }, + [{ turnId: 'turn-1', label: 'first question' }], + ); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + terminal.input('resend this'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // The Host refuses the dispatch (blocked disposition): the quotes return + // to the staging for the retry, exactly as a failed admission would. + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Could not load skills')); + terminal.input('retry then'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('renders and submits multiple staged quotes in rewind order', async () => { + const terminal = new FakeTerminal(); + // Two quotes per rewind: the count, the listing, and the submit must all + // carry the rewind's order. + const driver = new TwoQuoteRewindDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:2')); + + // /quotes lists both, in rewind order. The rewind refilled the editor + // with the discarded prompt; Ctrl+C clears it so /quotes is not appended + // to it. + terminal.input('\x03'); + terminal.input('/quotes'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Staged quotes:')); + const transcript = plainTerminalOutput(terminal.output()); + const firstAt = transcript.indexOf('first excerpt'); + const secondAt = transcript.indexOf('second excerpt'); + assert.ok(firstAt !== -1 && secondAt > firstAt, 'listing keeps rewind order'); + + // The replacement submit carries both refs, in the same order. + terminal.input('resend with both'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'first excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + { text: 'second excerpt', label: 'later turn', sourceTurnId: 'turn-0' }, + ]); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('lists staged quotes mid-turn through the local disposition', async () => { + const terminal = new FakeTerminal(); + const driver = new MidTurnQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // A running Turn claims busy; /quotes is composer-side staging and must + // still route through the local mid-turn disposition. driver.startBlockingTurn(); await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + terminal.input('/quotes'); terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('a large pasted excerpt')); + assert.ok( + plainTerminalOutput(terminal.output()).includes('Staged quotes:'), + 'the mid-turn listing renders the staged quotes', + ); - // End the turn: the runner is idle while the Host still holds the queued - // message. driver.turnGate.resolve(); await waitFor(() => terminal.progressStates.at(-1) === false); - // Deliberately hold the session's activity lease, then start the idle - // `/session`: runControl parks on activities.acquire before it can reach - // the switch (#5265 review). - const heldLease = await activities.acquire(driver.getSessionId()!); - terminal.input('/session session-other'); + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('restages a steered quote submit when it is retracted mid-turn', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // The running Turn turns the submit into a steering message carrying the + // staged quotes; the staging clears as it dispatches. + driver.startBlockingTurn(); await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); - // Shift+Left during that wait: the root key handler still retracts, and the - // retraction must land before the parked switch re-keys the driver — the - // switched-session fence would otherwise discard what the Host already - // removed from the queue (#5265 review). - driver.retractGate = deferred(); + // Shift+Left takes the message back. The Host's queue.retract returns the + // full MessageContent, so the quotes ride the retraction — and they must + // land back in the staging instead of vanishing with the queue row + // (#5109 review, third round). terminal.input('\x1b[1;2D'); // Shift+Left await waitFor(() => driver.retractCalls === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); - heldLease.release(); - // Give the parked switch every chance to race the retraction: without a - // guard it re-keys within a few ticks of the lease release, while the - // guarded path stays parked until the gate below opens. - await Promise.race([ - waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch:'))), - delay(50), + // The retry carries them again. + terminal.input('retry then'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, ]); - driver.retractGate.resolve(); - await waitFor(() => driver.getSessionId() === 'session-other'); - const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); - const switchDone = driver.eventLog.findIndex((entry) => entry.startsWith('switch:')); - assert.ok(retractDone !== -1, 'the retraction completed'); - assert.ok( - switchDone !== -1 && retractDone < switchDone, - 'the switch re-keys only after the retraction lands: ' + driver.eventLog.join(','), - ); - assert.ok( - editorInputText(terminal)?.includes('queued resend') === true, - 'the retracted text must reach the editor instead of being discarded by the switched-session fence', - ); - assert.equal( - driver.retractedTexts.at(-1), - 'queued resend', - 'the retraction lands with the payload it was asked for', - ); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); exitMaka(terminal); await Promise.race([ @@ -8757,9 +8914,9 @@ Slug openai-work ]); }); - test('discards a retraction that lands after a mid-turn session switch', async () => { + test('restages the quoted message among several retracted queue entries', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8770,28 +8927,33 @@ Slug openai-work terminal, }); + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + driver.startBlockingTurn(); await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); + // One quoted steering message and one plain queued follow-up. + terminal.input('quoted resend'); terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); await waitFor(() => driver.queuedRows.length === 1); + terminal.input('plain follow-up'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.equal(driver.submittedQuotes[1], undefined); + await waitFor(() => driver.queuedRows.length === 2); - // Hold the retraction in flight, switch mid-turn, then release it. The - // retraction describes the session we left: its text must not refill the - // session we landed on, where the next submit could carry the abandoned - // draft (#5109 review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left + terminal.input('\x1b[1;2D'); // Shift+Left retracts both. await waitFor(() => driver.retractCalls === 1); - driver.switchSession('session-other'); - driver.retractGate.resolve(); - await waitFor(() => driver.retractedTexts.length === 1); - await delay(30); - assert.doesNotMatch( - editorInputText(terminal) ?? '', - /queued resend/, - 'the abandoned retraction must not refill the switched-to session', - ); + // The quotes survive a multi-message retraction; the texts are both back. + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return screen.includes('quoted resend') && screen.includes('plain follow-up'); + }); driver.turnGate.resolve(); await waitFor(() => terminal.progressStates.at(-1) === false); @@ -8805,9 +8967,9 @@ Slug openai-work ]); }); - test('drains a retraction started before /new re-keys', async () => { + test('does not retract the switched-to queue when enqueues settle after a switch', async () => { const terminal = new FakeTerminal(); - const driver = new RetractingDriver([{ turnId: 'turn-1', label: 'first question' }]); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); const run = runMakaPiTui({ title: 'Maka', driver, @@ -8817,408 +8979,1434 @@ Slug openai-work permissionMode: 'ask', terminal, }); - try { - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); - terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); - // The Shift+Left retraction is held on the Host call when /new arrives: - // startNewSession re-keys onto a fresh Session while the Host has - // already removed the queued entries, so a response landing after the - // re-key would be discarded by the new-session fence. /new waits in the - // same switch window as /session (#5265 review). - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - terminal.input('/new'); - terminal.input('\r'); - await Promise.race([waitFor(() => driver.startNewSessionCalls === 1), delay(50)]); - assert.equal( - driver.startNewSessionCalls, - 0, - '/new parks behind the pending retraction: ' + driver.eventLog.join(','), - ); + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); - driver.retractGate.resolve(); - await waitFor(() => driver.startNewSessionCalls === 1); - const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); - assert.ok(retractDone !== -1, 'the retraction completed'); - assert.ok( - editorInputText(terminal)?.includes('queued resend') === true, - 'the retracted text must reach the editor instead of being discarded by the new-session re-key', - ); - } finally { - exitMaka(terminal); - await Promise.race([ - run, - delay(CLOSE_BUDGET_MS).then(() => { - throw new Error('TUI did not close during test cleanup'); - }), - ]); - } - }); + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); - test('drains a pending retraction before a navigation re-keys', async () => { - // Every navigation re-key removes the queue entries a pending retraction - // was asked for: the Host answers it while the driver still points at the - // asking session, so a response landing after the re-key would be - // discarded by the target-session fence (#5265 review). Each entry path - // drains the retraction inside its switch window first. - const queueOntoBlockingTurn = async ( - driver: RetractingSideConversationDriver, - terminal: FakeTerminal, - text = 'queued resend', - ) => { - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input(text); - terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - }; - const endTurn = async ( - driver: RetractingSideConversationDriver, - terminal: FakeTerminal, - ): Promise => { - driver.turnGate.resolve(); + // Park a second enqueue inside the driver, then Shift+Left: the runner must + // wait for that enqueue, notice the session moved underneath it, and drop + // the retraction *before* calling retractQueued — otherwise the Host call + // retracts the switched-to session's queue and the mismatch fence then + // discards those messages silently (#5109 review). + driver.enqueueGate = deferred(); + terminal.input('second queued'); + terminal.input('\r'); + terminal.input('\x1b[1;2D'); // Shift+Left: waits on the parked enqueue + driver.switchSession('session-other'); + driver.enqueueGate.resolve(); + await waitFor(() => driver.submittedQuotes.length === 2); + await delay(30); + assert.equal(driver.retractCalls, 0, 'the moved-past retraction must not run'); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('keeps the interrupt stop bound to the session it was asked for', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Ctrl+C interrupts the running turn; the pre-stop retraction is held on + // a gate while the user switches sessions. The switch owns stopping the + // old turn — the fenced interrupt must not stop the session it landed on + // (#5109 review). + driver.retractGate = deferred(); + terminal.input('\x1b'); // double Escape arms and fires the interrupt + terminal.input('\x1b'); + await waitFor(() => driver.retractCalls === 1); + driver.switchSession('session-other'); + driver.retractGate.resolve(); + await waitFor(() => driver.retractedQuoteLoads.length === 1); + await delay(30); + assert.equal(driver.stopCalls, 0, 'the fenced stop must not hit the switched-to session'); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('serializes a mid-turn session switch behind an in-flight retraction', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // The Shift+Left retraction is held on the Host call; a mid-turn `/session` + // arriving while it is in flight must wait for it — the retracted text + // and quotes land in the session they were asked for before the driver + // re-keys (#5109 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-other'); + terminal.input('\r'); + driver.retractGate.resolve(); + await delay(200); + if (process.env.PROFILE_DEBUG) { + console.log('DEBUG eventLog:', driver.eventLog.join(' | ')); + console.log('DEBUG queuedRows:', driver.queuedRows.length); + console.log('DEBUG stopCalls:', driver.stopCalls); + } + await waitFor(() => driver.retractedQuoteLoads.length === 1); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const switchDone = driver.eventLog.findIndex((entry) => entry.startsWith('switch:')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + switchDone === -1 || retractDone < switchDone, + 'the switch waits behind the retraction: ' + driver.eventLog.join(','), + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('refuses a second mid-turn /session while the first waits for the retraction drain', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Hold the retraction in flight, then issue two mid-turn `/session` + // commands. The first parks inside the retraction drain before it sets + // `detaching`, so the second must not pass the same guard and re-key the + // driver concurrently with the first (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-a'); + terminal.input('\r'); + await delay(50); // the first switch is parked inside the retraction drain + terminal.input('/session session-b'); + terminal.input('\r'); + await delay(50); + driver.retractGate.resolve(); + await delay(50); + const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); + assert.equal( + switchStarts.length, + 1, + 'the second mid-turn switch must be refused while the first holds the detach: ' + + driver.eventLog.join(','), + ); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('refuses an idle /session while a mid-turn detach is still draining its retraction', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // The mid-turn detach parks inside the retraction drain; the turn ends + // while it waits, so an idle `/session` is no longer busy-gated. It must + // still wait for the in-flight detach instead of re-keying the driver + // concurrently (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-a'); + terminal.input('\r'); + await delay(50); // the detach is parked inside the retraction drain + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('/session session-b'); + terminal.input('\r'); + await delay(50); + driver.retractGate.resolve(); + await delay(50); + const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); + assert.equal( + switchStarts.length, + 1, + 'the idle switch must be refused while the detach still holds the driver: ' + + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('refuses an idle /side while a mid-turn detach is still draining its retraction', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // The mid-turn detach parks inside the retraction drain; the turn ends + // while it waits, so an idle `/side` is no longer busy-gated. Its re-key + // must be refused instead of racing the in-flight detach's own re-key + // (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-a'); + terminal.input('\r'); + await delay(50); // the detach is parked inside the retraction drain + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('/side'); + terminal.input('\r'); + await delay(50); + driver.retractGate.resolve(); + await delay(50); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('open-start:')), + 'the idle side open must be refused while the detach still holds the driver: ' + + driver.eventLog.join(','), + ); + const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); + assert.equal( + switchStarts.length, + 1, + 'the detach must complete exactly once: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('drains a retraction started before /new re-keys', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.queuedRows.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Shift+Left retraction is held on the Host call when /new arrives: + // startNewSession re-keys onto a fresh Session while the Host has + // already removed the queued entries, so a response landing after the + // re-key would be discarded by the new-session fence. /new waits in the + // same switch window as /session (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/new'); + terminal.input('\r'); + await Promise.race([waitFor(() => driver.startNewSessionCalls === 1), delay(50)]); + assert.equal( + driver.startNewSessionCalls, + 0, + '/new parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.startNewSessionCalls === 1); + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must reach the editor instead of being discarded by the new-session re-key', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('refuses /new while a mid-turn detach is still draining its retraction', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // `/new` must stay reachable while a Turn runs (#3210 review), so the end + // of the Turn does not gate it either: while the mid-turn detach is still + // parked inside the retraction drain, startNewSession's re-key must be + // refused instead of racing the detach's own re-key (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-a'); + terminal.input('\r'); + await delay(50); // the detach is parked inside the retraction drain + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('/new'); + terminal.input('\r'); + await delay(50); + driver.retractGate.resolve(); + await delay(50); + assert.equal( + driver.startNewSessionCalls, + 0, + '/new must be refused while the detach still holds the driver: ' + driver.eventLog.join(','), + ); + const switchStarts = driver.eventLog.filter((entry) => entry.startsWith('switch-start:')); + assert.equal( + switchStarts.length, + 1, + 'the detach must complete exactly once: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('serializes an idle /session switch behind a retraction started during its activity wait', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const activities = new SessionActivityRegistry(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + turnActivity: createTestTurnActivity(activities), + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Deliberately hold the session's activity lease, then start the idle + // `/session`: runControl parks on activities.acquire before it can reach + // the switch (#5265 review). + const heldLease = await activities.acquire(driver.getSessionId()!); + terminal.input('/session session-other'); + terminal.input('\r'); + await waitFor(() => terminal.progressStates.at(-1) === true); + + // Shift+Left during that wait: the root key handler still retracts, and the + // retraction must land before the parked switch re-keys the driver — the + // switched-session fence would otherwise discard what the Host already + // removed from the queue (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + + heldLease.release(); + // Give the parked switch every chance to race the retraction: without a + // guard it re-keys within a few ticks of the lease release, while the + // guarded path stays parked until the gate below opens. + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch:'))), + delay(50), + ]); + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-other'); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const switchDone = driver.eventLog.findIndex((entry) => entry.startsWith('switch:')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + switchDone !== -1 && retractDone < switchDone, + 'the switch re-keys only after the retraction lands: ' + driver.eventLog.join(','), + ); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must reach the editor instead of being discarded by the switched-session fence', + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('discards a retraction that lands after a mid-turn session switch', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Hold the retraction in flight, switch mid-turn, then release it. The + // retraction describes the session we left: its quotes must not stage + // into the session we landed on, where the next submit could carry the + // abandoned context (#5109 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + driver.switchSession('session-other'); + driver.retractGate.resolve(); + await waitFor(() => driver.retractedQuoteLoads.length === 1); + await delay(30); + assert.doesNotMatch( + plainTerminalOutput(terminal.screenOutput()), + /quotes:1/, + 'the abandoned retraction must not stage into the switched-to session', + ); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('blocks Shift+Left for the entire in-progress switch window, not just its drain', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Park the idle `/session` INSIDE the driver's switch, before it re-keys: + // the real driver spends this window stopping user commands and opening + // the target Session channel, and Shift+Left stays live throughout (#5265 + // review). + driver.switchGate = deferred(); + driver.retractGate = deferred(); + terminal.input('/session session-other'); + terminal.input('\r'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch-start:'))); + + // Shift+Left in that window must not send a retraction for the old Session: + // the Host would remove the queued entries while the driver re-keys, and + // the switched-session fence would then discard what the Host removed. + terminal.input('\x1b[1;2D'); // Shift+Left + await delay(50); + assert.equal( + driver.retractCalls, + 0, + 'no queue.retract may cross while the switch is still re-keying', + ); + + // Re-key while a retraction response would still be pending, then let any + // response land: the queued entries must survive untouched either way. + driver.switchGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-other'); + driver.retractGate.resolve(); + await delay(30); + assert.equal(driver.queuedRows.length, 1, 'the queued entry stays on the Host queue'); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), + 'no retraction completed: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('blocks Shift+Left while a side conversation is still opening', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Park `/side` inside the driver's open, before it re-keys onto the side + // Session: the real driver spends this window forking the parent and + // switching onto the fork, and Shift+Left stays live throughout (#5265 + // review). + driver.openGate = deferred(); + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))); + + // Shift+Left in that window must not send a retraction for the parent + // Session: the Host would remove its queued entries while the driver + // re-keys, and the side-session fence would then discard what the Host + // removed. + terminal.input('\x1b[1;2D'); // Shift+Left + await delay(50); + assert.equal( + driver.retractCalls, + 0, + 'no queue.retract may cross while the side open is still re-keying', + ); + + // Let the open land: the queued entry must survive untouched either way. + driver.openGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + await delay(30); + assert.equal(driver.queuedRows.length, 1, 'the queued entry stays on the Host queue'); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), + 'no retraction completed: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('blocks Shift+Left while a side conversation is still closing', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Open for real (ungated), then park the close inside the driver's re-key + // back onto the parent: the real close spends this window switching onto + // the parent Session before it tears the side copy down (#5265 review). + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + + driver.closeGate = deferred(); + terminal.input('\x03'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))); + + // Shift+Left in that window would retract the parent's queued entry under the + // side Session's identity; the close's fence would then discard the + // returned text and quotes while the entries are gone from the Host. + terminal.input('\x1b[1;2D'); // Shift+Left + await delay(50); + assert.equal( + driver.retractCalls, + 0, + 'no queue.retract may cross while the side close is still re-keying', + ); + + // Let the close land: the queued entry must survive untouched either way. + driver.closeGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-branch'); + await waitFor(() => driver.closedSides.length === 1); + await delay(30); + assert.equal(driver.queuedRows.length, 1, 'the queued entry stays on the Host queue'); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), + 'no retraction completed: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('drains a retraction started before an idle side conversation opens', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Shift+Left retraction is held on the Host call when `/side` arrives: + // the open must drain it inside its switch window. The Host removes the + // parent's queued entries as the retraction resolves, so a response + // landing after the re-key onto the side Session would be discarded by + // the side-session fence (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/side'); + terminal.input('\r'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('open-start:')), + 'the side open parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const openDone = driver.eventLog.findIndex((entry) => entry.startsWith('open:side-1')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + openDone !== -1 && retractDone < openDone, + 'the side open re-keys only after the retraction lands: ' + driver.eventLog.join(','), + ); + assert.equal( + editorInputText(terminal) ?? '', + '', + 'the side editor must open on the side draft, not the recovered parent text', + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + // Closing again returns to the parent, whose draft owns the recovered + // text: it was captured while the drain's restore was still in the + // editor, and the side view never sees it (#5265 review). + terminal.input('\x03'); + await waitFor(() => driver.getSessionId() === 'session-branch'); + assert.equal(driver.closedSides.length, 1); + // The editor restore lands after the driver re-keys, so wait for the + // content itself rather than asserting behind the session-id wait. + await waitFor( + () => editorInputText(terminal)?.includes('queued resend') === true, + 'the recovered parent text must survive the side round trip in the parent draft', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('drains a retraction started before a mid-turn side conversation opens', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Same reachable order as the idle open, with the Turn still running: + // Shift+Left parks on the Host call and the mid-turn `/side` detaches behind + // it. The detach re-keys onto the side Session, so the open must drain + // the retraction first or its response is fenced away (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/side'); + terminal.input('\r'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('open-start:')), + 'the mid-turn side open parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const openDone = driver.eventLog.findIndex((entry) => entry.startsWith('open:side-1')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + openDone !== -1 && retractDone < openDone, + 'the mid-turn side open re-keys only after the retraction lands: ' + + driver.eventLog.join(','), + ); + assert.equal( + editorInputText(terminal) ?? '', + '', + 'the mid-turn side editor must open on the side draft, not the recovered parent text', + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + driver.turnGate.resolve(); + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('drains a retraction started before a side conversation closes', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // Open for real, then run a side Turn and queue onto it, and leave the + // runner idle with the entry still on the side Session's Host queue. + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('side follow-up'); + terminal.input('\r'); + await waitFor(() => driver.queuedRows.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Shift+Left retraction is held on the Host call when the close arrives: + // the close must drain it before re-keying onto the parent, or the + // close's session fence discards the response for entries the Host + // already removed (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('\x03'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('close-start:')), + 'the side close parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('retract-done:'))); + + // The close was admitted against an empty draft, so the text the drain + // restored must abort it: closing here would overwrite the recovered + // side message with the parent draft and lose it (#5265 review). + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Side conversation kept open'), + ); + assert.equal( + driver.closedSides.length, + 0, + 'the close must not proceed once the drain restored a draft', + ); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('close:')), + 'no close re-key may happen after the drain restored text: ' + driver.eventLog.join(','), + ); + assert.equal(driver.getSessionId(), 'side-1'); + assert.ok( + editorInputText(terminal)?.includes('side follow-up') === true, + 'the recovered side text stays visible in the side editor', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('drains a retraction started before a rewind branches', async () => { + const terminal = new FakeTerminal(); + const driver = new PerRewindBranchRetractingDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + // The first rewind staged its own quote and the queued submit rode it. + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'excerpt from rewind 1', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + driver.turnGate.resolve(); await waitFor(() => terminal.progressStates.at(-1) === false); - }; - const assertDrainedBefore = ( - driver: RetractingSideConversationDriver, - rekeyMarker: string, - label: string, - ) => { - const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); - const rekeyDone = driver.eventLog.findIndex((entry) => entry.startsWith(rekeyMarker)); - assert.ok(retractDone !== -1, `${label}: the retraction completed`); + + // The Shift+Left retraction is held on the Host call when a second rewind is + // selected: each rewind branches onto a fresh Session, so the pending + // retraction crosses a re-key and the branch-switch fence would discard + // what the Host already removed from the first branch's queue (#5265 + // review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => driver.pickerOpens === 2); + terminal.input('\r'); + await Promise.race([waitFor(() => driver.rewound.length === 2), delay(50)]); + assert.equal( + driver.rewound.length, + 1, + 'the rewind parks behind the pending retraction instead of branching under it', + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.rewound.length === 2); + await waitFor(() => driver.getSessionId() === 'session-branch-2'); assert.ok( - rekeyDone !== -1 && retractDone < rekeyDone, - `${label}: the re-key happens only after the retraction lands: ${driver.eventLog.join(',')}`, + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must survive the rewind instead of being discarded by the branch fence', ); - }; - const cases: Array<{ - name: string; - setup: (driver: RetractingSideConversationDriver, terminal: FakeTerminal) => Promise; - park: (driver: RetractingSideConversationDriver, terminal: FakeTerminal) => Promise; - expect: (driver: RetractingSideConversationDriver, terminal: FakeTerminal) => Promise; - }> = [ - { - name: 'an idle side conversation opens', - setup: async (driver, terminal) => { - await queueOntoBlockingTurn(driver, terminal); - await endTurn(driver, terminal); - }, - park: async (driver, terminal) => { - terminal.input('/side'); - terminal.input('\r'); - await Promise.race([ - waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))), - delay(50), - ]); - assert.ok( - !driver.eventLog.some((entry) => entry.startsWith('open-start:')), - 'the side open parks behind the pending retraction: ' + driver.eventLog.join(','), - ); - }, - expect: async (driver, terminal) => { - await waitFor(() => driver.getSessionId() === 'side-1'); - assertDrainedBefore(driver, 'open:side-1', 'the side open'); - assert.equal( - driver.retractedTexts.at(-1), - 'queued resend', - 'the retraction lands with the payload it was asked for', - ); - assert.equal( - editorInputText(terminal) ?? '', - '', - 'the side editor opens on the side draft, not the recovered parent text', - ); - // Closing again returns to the parent, whose draft owns the recovered - // text: it was captured while the drain's restore was still in the - // editor, and the side view never sees it. - terminal.input('\x03'); - await waitFor(() => driver.getSessionId() === 'session-1'); - assert.equal(driver.closedSides.length, 1); - await waitFor( - () => editorInputText(terminal)?.includes('queued resend') === true, - 'the recovered parent text must survive the side round trip in the parent draft', - ); - }, - }, - { - name: 'a mid-turn side conversation opens', - setup: async (driver, terminal) => { - // Same reachable order as the idle open, with the Turn still running: - // Shift+Left parks on the Host call and the mid-turn `/side` detaches - // behind it (#5265 review). - await queueOntoBlockingTurn(driver, terminal); - }, - park: async (driver, terminal) => { - terminal.input('/side'); - terminal.input('\r'); - await Promise.race([ - waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))), - delay(50), - ]); - assert.ok( - !driver.eventLog.some((entry) => entry.startsWith('open-start:')), - 'the mid-turn side open parks behind the pending retraction: ' + - driver.eventLog.join(','), - ); - }, - expect: async (driver, terminal) => { - await waitFor(() => driver.getSessionId() === 'side-1'); - assertDrainedBefore(driver, 'open:side-1', 'the mid-turn side open'); - assert.equal( - driver.retractedTexts.at(-1), - 'queued resend', - 'the retraction lands with the payload it was asked for', - ); - assert.equal( - editorInputText(terminal) ?? '', - '', - 'the mid-turn side editor opens on the side draft, not the recovered parent text', - ); - driver.turnGate.resolve(); - }, - }, - { - name: 'a side conversation closes', - setup: async (driver, terminal) => { - // Open for real, then run a side Turn and queue onto it, and leave - // the runner idle with the entry still on the side Session's Host - // queue. - terminal.input('/side'); - terminal.input('\r'); - await waitFor(() => driver.getSessionId() === 'side-1'); - await waitFor(() => terminal.progressStates.at(-1) === false); - await queueOntoBlockingTurn(driver, terminal, 'side follow-up'); - await endTurn(driver, terminal); - }, - park: async (driver, terminal) => { - terminal.input('\x03'); - await Promise.race([ - waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))), - delay(50), - ]); - assert.ok( - !driver.eventLog.some((entry) => entry.startsWith('close-start:')), - 'the side close parks behind the pending retraction: ' + driver.eventLog.join(','), - ); - }, - expect: async (driver, terminal) => { - await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('retract-done:'))); - // The close was admitted against an empty draft, so the text the - // drain restored must abort it: closing here would overwrite the - // recovered side message with the parent draft and lose it. - await waitFor(() => - plainTerminalOutput(terminal.output()).includes('Side conversation kept open'), - ); - assert.equal( - driver.closedSides.length, - 0, - 'the close must not proceed once the drain restored a draft', - ); - assert.ok( - !driver.eventLog.some((entry) => entry.startsWith('close:')), - 'no close re-key may happen after the drain restored text: ' + - driver.eventLog.join(','), - ); - assert.equal(driver.getSessionId(), 'side-1'); - assert.ok( - editorInputText(terminal)?.includes('side follow-up') === true, - 'the recovered side text stays visible in the side editor', - ); - }, - }, - { - name: 'a rewind branches', - setup: async (driver, terminal) => { - await queueOntoBlockingTurn(driver, terminal); - await endTurn(driver, terminal); - }, - park: async (driver, terminal) => { - terminal.input('/rewind'); - terminal.input('\r'); - await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); - terminal.input('\r'); - await Promise.race([waitFor(() => driver.rewound.length === 1), delay(50)]); - assert.equal( - driver.rewound.length, - 0, - 'the rewind parks behind the pending retraction instead of branching under it', - ); - }, - expect: async (driver, terminal) => { - await waitFor(() => driver.rewound.length === 1); - await waitFor(() => driver.getSessionId() === 'session-branch'); - const retractDone = driver.eventLog.findIndex((entry) => - entry.startsWith('retract-done:'), - ); - assert.ok(retractDone !== -1, 'the retraction completed before the branch landed'); - assert.ok( - editorInputText(terminal)?.includes('queued resend') === true, - 'the retracted text must survive the rewind instead of being discarded by the branch fence', - ); - }, - }, - ]; - for (const c of cases) { - const terminal = new FakeTerminal(); - const driver = new RetractingSideConversationDriver([ - { turnId: 'turn-1', label: 'first question' }, + + // Resubmitting pins whose quotes the replacement carries: the second + // rewind's own quote replaced the retracted message's quote — rewind + // quotes are a replacement, not an accumulation (#5109, #5265 review). + // Distinct quotes per rewind are what makes the replacement visible. + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'excerpt from rewind 2', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), ]); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'claude-sonnet-4-5', - connectionSlug: 'claude-subscription', - permissionMode: 'ask', - terminal, - }); - try { - await c.setup(driver, terminal); - // Hold the retraction on the Host call, then start the navigation: the - // retraction must resolve while the driver still points at the asking - // session. - driver.retractGate = deferred(); - terminal.input('\x1b[1;2D'); // Shift+Left - await waitFor(() => driver.retractCalls === 1); - await c.park(driver, terminal); - driver.retractGate?.resolve(); - await c.expect(driver, terminal); - } finally { - exitMaka(terminal); - await Promise.race([ - run, - delay(CLOSE_BUDGET_MS).then(() => { - throw new Error('TUI did not close during test cleanup'); - }), - ]); - } } }); - test('blocks Shift+Left while a navigation re-key is still in flight', async () => { - // Every navigation re-key — an idle /session, a side open, a side close — - // spends asynchronous Host work between holding the switch window and - // adopting the target Session. Shift+Left inside that window would address - // the session being left: the Host removes its queued entries while the - // driver re-keys, and the switched-session fence would then discard what - // the Host removed. The window blocks new retractions instead (#5265 - // review). - const cases: Array<{ - name: string; - park: (driver: RetractingSideConversationDriver, terminal: FakeTerminal) => Promise; - release: (driver: RetractingSideConversationDriver) => Promise; - }> = [ - { - name: 'an in-progress /session switch', - park: async (driver, terminal) => { - // Park the idle /session INSIDE the driver's switch, before it - // re-keys: the real driver spends this window stopping user commands - // and opening the target Session channel. - driver.switchGate = deferred(); - terminal.input('/session session-other'); - terminal.input('\r'); - await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch-start:'))); - }, - release: async (driver) => { - driver.switchGate?.resolve(); - await waitFor(() => driver.getSessionId() === 'session-other'); - }, - }, - { - name: 'an in-progress side conversation open', - park: async (driver, terminal) => { - driver.openGate = deferred(); - terminal.input('/side'); - terminal.input('\r'); - await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))); - }, - release: async (driver) => { - driver.openGate?.resolve(); - await waitFor(() => driver.getSessionId() === 'side-1'); - }, - }, - { - name: 'an in-progress side conversation close', - park: async (driver, terminal) => { - // Open for real (ungated) first: the close then parks inside the - // driver's re-key back onto the parent. - terminal.input('/side'); - terminal.input('\r'); - await waitFor(() => driver.getSessionId() === 'side-1'); - await waitFor(() => terminal.progressStates.at(-1) === false); - driver.closeGate = deferred(); - terminal.input('\x03'); - await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))); - }, - release: async (driver) => { - driver.closeGate?.resolve(); - await waitFor(() => driver.getSessionId() === 'session-1'); - await waitFor(() => driver.closedSides.length === 1); - }, - }, - ]; - for (const c of cases) { - const terminal = new FakeTerminal(); - const driver = new RetractingSideConversationDriver([ - { turnId: 'turn-1', label: 'first question' }, + test('carries retraction-restored quotes through a side conversation round trip', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The open drains the retraction inside its switch window: the Host + // removes the parent's queued entries as the retraction resolves, so the + // quotes it returns are the only copy left. They must ride the parent + // draft through the side round trip instead of dying on the switch's + // staging clear (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/side'); + terminal.input('\r'); + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + // Close again: the parent draft and its quotes come back together, so + // the resubmit still carries the recovered quotes. + terminal.input('\x03'); + await waitFor(() => driver.getSessionId() === 'session-branch'); + await waitFor( + () => editorInputText(terminal)?.includes('queued resend') === true, + 'the recovered parent text must come back with the close', + ); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('carries retraction-restored quotes across a /session switch', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The switch drains the held retraction before it re-keys: the recovered + // text reaches the editor and its quotes are staged for the session the + // retraction was asked in. The switch still clears turn-staged quotes + // outright, but these ride the recovered text — which the editor keeps + // across the switch — so they must survive, keyed to wherever the text + // now lives (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-other'); + terminal.input('\r'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('switch-start:')), + 'the switch parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-other'); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the recovered text reaches the editor across the switch', + ); + + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('keeps a failed retraction-restored quote restage across a later session switch', async () => { + const terminal = new FakeTerminal(); + const driver = new FailingRetryRetractingDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The retraction restores the queued text and its quotes into the + // draft-following lane (#5265 review). + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + await waitFor( + () => editorInputText(terminal)?.includes('queued resend') === true, + 'the retraction restores the queued text to the editor', + ); + + // The retry's admission fails: the restage must keep the quotes in the + // draft-following lane instead of demoting them to turn-staged — the + // next switch clears turn-staged quotes outright, stranding the + // recovered text without its context (#5265 review, second P2). + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + driver.hold(new Error('admission outcome unknown')); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('admission outcome unknown'), + ); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + terminal.input('/session session-other'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'session-other'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + terminal.input('retry after switch'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 3); + assert.deepEqual(driver.submittedQuotes[2], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), ]); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'claude-sonnet-4-5', - connectionSlug: 'claude-subscription', - permissionMode: 'ask', - terminal, - }); - try { - driver.startBlockingTurn(); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('queued resend'); - terminal.input('\r'); - await waitFor(() => driver.queuedRows.length === 1); - // End the turn: the runner is idle while the Host still holds the - // queued message. - driver.turnGate.resolve(); - await waitFor(() => terminal.progressStates.at(-1) === false); + } + }); - await c.park(driver, terminal); - terminal.input('\x1b[1;2D'); // Shift+Left inside the re-key window - await delay(50); - assert.equal( - driver.retractCalls, - 0, - `${c.name}: no queue.retract may cross while the driver is still re-keying`, - ); + test('keeps drained quotes when a rewind branches without quotes of its own', async () => { + const terminal = new FakeTerminal(); + const driver = new PerRewindBranchRetractingDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + // Only the first rewind carries quotes: the second branches quote-less, so + // nothing replaces the quotes the drain restored — they must survive with + // the recovered text instead of dying on the branch switch's clear (#5265 + // review). + driver.rewindQuotes = (seq) => + seq === 1 + ? [{ text: 'excerpt from rewind 1', label: 'earlier turn', sourceTurnId: 'turn-0' }] + : []; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); - await c.release(driver); - await delay(30); - assert.equal( - driver.queuedRows.length, - 1, - `${c.name}: the queued entry stays on the Host queue`, - ); - assert.ok( - !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), - `${c.name}: no retraction completed: ${driver.eventLog.join(',')}`, - ); - } finally { - exitMaka(terminal); - await Promise.race([ - run, - delay(CLOSE_BUDGET_MS).then(() => { - throw new Error('TUI did not close during test cleanup'); - }), - ]); - } + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + driver.retractGate = deferred(); + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.retractCalls === 1); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => driver.pickerOpens === 2); + terminal.input('\r'); + driver.retractGate.resolve(); + await waitFor(() => driver.rewound.length === 2); + await waitFor(() => driver.getSessionId() === 'session-branch-2'); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must survive the rewind', + ); + + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'excerpt from rewind 1', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); } }); @@ -9333,6 +10521,71 @@ Slug openai-work } }); + test('aborts a side close when a quote-only retraction left staged quotes', async () => { + const terminal = new FakeTerminal(); + const driver = new QuoteOnlyRetractingSideDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('side follow-up'); + terminal.input('\r'); + await waitFor(() => driver.queuedRows.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Quote-only retraction: no text comes back, so the editor stays empty, + // but the quotes it returns are staged and ride the next side submit. A + // close that only checks the editor text would proceed and drop the + // staging on the switch (#5265 review). + driver.quoteOnlyRetraction = [ + { text: 'side quote', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]; + terminal.input('\x1b[1;2D'); // Shift+Left + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('retract-done:'))); + assert.equal(editorInputText(terminal) ?? '', '', 'a quote-only retraction restores no text'); + + terminal.input('\x03'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Side conversation kept open — the staged quotes were kept.', + ), + ); + assert.equal(driver.closedSides.length, 0, 'the staged quotes must abort the close'); + assert.equal(driver.getSessionId(), 'side-1'); + + // The staged quotes still ride the next side submit. + terminal.input('resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'side quote', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + test('does not blame a retraction for a draft the user typed when keeping the side open', async () => { const terminal = new FakeTerminal(); const driver = new RetractingSideConversationDriver([ @@ -13093,7 +14346,7 @@ class SteeringTurnDriver extends FakeSessionDriver { }; } - async retractQueued(): Promise<{ text: string; messageIds: readonly string[] }> { + async retractQueued(): Promise { this.retractCalls += 1; const retracted = [...this.steering, ...this.followup]; const joined = retracted.map((entry) => entry.text).join('\n\n'); @@ -13102,7 +14355,7 @@ class SteeringTurnDriver extends FakeSessionDriver { this.emitQueueUpdate(); this.wakeTurn?.(); this.wakeTurn = null; - return { text: joined, messageIds: retracted.map((entry) => entry.messageId) }; + return { text: joined, messageIds: retracted.map((entry) => entry.messageId), quotes: [] }; } // Simulates the runtime consuming the steering queue at a step boundary @@ -14775,31 +16028,147 @@ class DeferredRewindDriver extends RewindDriver { } /** - * A gated running Turn on top of the rewind driver, plus the Host-side queue - * and retraction seams the fencing tests observe: submits land as queue rows - * once the Turn is running, `retractQueued`/`switchSession` park on gates, and - * every retraction logs which Session it resolved for (#5265 review). + * Rewinds into a branch and returns the selected turn's QuoteRefs, the way + * the runtime-host driver does for a quoted turn (#5109). Records every + * submit's staged quotes so tests can assert what the replacement prompt + * actually carries. */ -class RetractingDriver extends RewindDriver { - readonly queuedRows: Array<{ messageId: string; text: string }> = []; - readonly retractedTexts: string[] = []; - readonly eventLog: string[] = []; - retractGate: ReturnType> | undefined = undefined; - enqueueGate: ReturnType> | undefined = undefined; - switchGate: ReturnType> | undefined = undefined; - readonly turnGate = deferred(); - #startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; - #retractCalls = 0; - #stopCalls = 0; - #turnStarted = false; +class QuotedRewindDriver extends RewindDriver { + readonly submittedQuotes: Array = []; - get retractCalls(): number { - return this.#retractCalls; + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + return { + ...result, + quotes: [{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }], + }; } - get stopCalls(): number { - return this.#stopCalls; + override submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + this.submittedQuotes.push(options.quotes); + return super.submitMessage(text, options); + } +} + +/** + * The submit hangs until the test rejects it, so a failure callback can be + * observed after the runner moved on (for example across a Session switch). + * The rewound prompt is empty: a quote-only replacement (#5109 review). + */ +class HeldSubmitQuotedDriver extends QuotedRewindDriver { + hold!: (error: Error) => void; + + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + return { ...result, prompt: '' }; + } + + override submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + this.submittedQuotes.push(options.quotes); + return new Promise((_, reject) => { + this.hold = () => reject(new Error('admission outcome unknown')); + }); + } +} + +/** + * The submit hangs until the test resolves it without a receipt: the real + * driver resolves `undefined` for `outcome_unknown` and for an interruption + * after the dispatch went out, instead of rejecting (#5109 review). + */ +class UnknownOutcomeSubmitDriver extends HeldSubmitQuotedDriver { + resolveUnknown!: () => void; + + override submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + this.submittedQuotes.push(options.quotes); + return new Promise((resolve) => { + this.resolveUnknown = () => resolve(undefined); + }); + } +} + +/** + * Each rewind stages its own distinct quote text, so a test can tell whose + * staging a later submit actually carried. + */ +class PerRewindQuotedDriver extends HeldSubmitQuotedDriver { + #rewindCount = 0; + #pickerOpens = 0; + + override async listRewindTargets(): Promise { + this.#pickerOpens += 1; + return super.listRewindTargets(); + } + + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + this.#rewindCount += 1; + return { + ...result, + quotes: [ + { + text: `excerpt from rewind ${this.#rewindCount}`, + label: 'earlier turn', + sourceTurnId: 'turn-0', + }, + ], + }; + } + + /** How many times the rewind picker opened; picker renders share labels with + * the transcript, so scrollback text alone cannot tell two openings apart. */ + get pickerOpens(): number { + return this.#pickerOpens; + } +} + +/** The Host answers the replacement submit with a `blocked` disposition — + * the Skills the message named could not be resolved — instead of a Turn. */ +class BlockedQuotedRewindDriver extends QuotedRewindDriver { + readonly skillInvocation: SkillInvocationResult; + + constructor(skillInvocation: SkillInvocationResult, targets: RewindTarget[]) { + super(targets); + this.skillInvocation = skillInvocation; + } + + override async submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + this.submittedQuotes.push(options.quotes); + return { disposition: 'blocked', skillInvocation: this.skillInvocation }; + } +} + +/** One rewind stages two quotes, so ordering and count are observable. */ +class TwoQuoteRewindDriver extends QuotedRewindDriver { + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + return { + ...result, + quotes: [ + { text: 'first excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + { text: 'second excerpt', label: 'later turn', sourceTurnId: 'turn-0' }, + ], + }; } +} + +/** A running Turn plus a rewound prompt that refills nothing: /quotes must + * still route through the local mid-turn disposition with a clean editor. */ +class MidTurnQuotesDriver extends QuotedRewindDriver { + readonly turnGate = deferred(); + #startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; override subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { this.#startedTurnListener = listener; @@ -14808,10 +16177,7 @@ class RetractingDriver extends RewindDriver { }; } - /** Publishes the blocking Turn the test parks messages onto: while it runs, - * every submit queues on the Host instead of steering the Turn. */ startBlockingTurn(): void { - this.#turnStarted = true; const gate = this.turnGate; this.#startedTurnListener?.({ sessionId: this.getSessionId()!, @@ -14834,12 +16200,51 @@ class RetractingDriver extends RewindDriver { }); } + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + return { ...result, prompt: '' }; + } +} + +/** + * A queued or steered submit's quotes ride the queue entry: `retractQueued` + * returns them the way the Host's `queue.retract` returns the full + * `MessageContent`, so a retraction can hand them back to the staging. + */ +class RetractingQuotesDriver extends MidTurnQuotesDriver { + readonly queuedRows: Array<{ messageId: string; text: string; quotes: readonly QuoteRef[] }> = []; + readonly retractedQuoteLoads: Array = []; + readonly eventLog: string[] = []; + retractGate: ReturnType> | undefined = undefined; + enqueueGate: ReturnType> | undefined = undefined; + switchGate: ReturnType> | undefined = undefined; + #retractCalls = 0; + #stopCalls = 0; + #turnStarted = false; + + get retractCalls(): number { + return this.#retractCalls; + } + + get stopCalls(): number { + return this.#stopCalls; + } + + override startBlockingTurn(): void { + this.#turnStarted = true; + super.startBlockingTurn(); + } + override submitMessage( text: string, options: MakaSubmitMessageOptions, ): Promise { if (this.#turnStarted) { - this.queuedRows.push({ messageId: options.messageId, text }); + this.queuedRows.push({ + messageId: options.messageId, + text, + quotes: options.quotes ?? [], + }); if (this.enqueueGate) { const gate = this.enqueueGate; return gate.promise.then(() => super.submitMessage(text, options)); @@ -14852,15 +16257,17 @@ class RetractingDriver extends RewindDriver { this.#stopCalls += 1; } - async retractQueued(): Promise<{ text: string; messageIds: readonly string[] }> { + async retractQueued(): Promise { this.#retractCalls += 1; if (this.retractGate) await this.retractGate.promise; + const quotes = this.queuedRows.flatMap((row) => row.quotes); const retracted = { text: this.queuedRows.map((row) => row.text).join('\n\n'), messageIds: this.queuedRows.map((row) => row.messageId), + quotes, }; this.queuedRows.length = 0; - this.retractedTexts.push(retracted.text); + this.retractedQuoteLoads.push(quotes); this.eventLog.push(`retract-done:${this.sessionId}`); return retracted; } @@ -14879,12 +16286,33 @@ class RetractingDriver extends RewindDriver { } } +/** + * The queued entry rides out normally; once a retraction has drained, every + * later submit hangs until the test rejects it — the failed admission must + * restage the restored quotes without dropping their draft-following + * provenance (#5265 review). + */ +class FailingRetryRetractingDriver extends RetractingQuotesDriver { + hold!: (error: Error) => void; + + override submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + if (this.retractCalls === 0) return super.submitMessage(text, options); + this.submittedQuotes.push(options.quotes); + return new Promise((_, reject) => { + this.hold = () => reject(new Error('admission outcome unknown')); + }); + } +} + /** * Adds side-conversation re-keys to the retracting driver: `openGate` and * `closeGate` hold a test inside the open's or close's in-progress re-key, * the same parking spot `switchGate` provides for `/session` (#5265 review). */ -class RetractingSideConversationDriver extends RetractingDriver { +class RetractingSideConversationDriver extends RetractingQuotesDriver { openGate: ReturnType> | undefined = undefined; closeGate: ReturnType> | undefined = undefined; #parentStatusListener: @@ -14931,12 +16359,68 @@ class RetractingSideConversationDriver extends RetractingDriver { } } +/** + * Each rewind branches onto a fresh session id, the way the real driver mints + * a new branch per rewind: a retraction asked before a second rewind crosses a + * session change, so the branch-switch fence applies to its response (#5265 + * review). + */ +class PerRewindBranchRetractingDriver extends RetractingQuotesDriver { + #rewindSeq = 0; + #pickerOpens = 0; + /** Quotes each rewind branches with; `seq` is 1-based. Each rewind mints a + * distinct quote, so a test can tell the rewound turn's own quotes from the + * ones a retraction restored — with one shared quote the replacement stays + * invisible (#5265 review). */ + rewindQuotes: (seq: number) => QuoteRef[] = (seq) => [ + { text: `excerpt from rewind ${seq}`, label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]; + + override async listRewindTargets(): Promise { + this.#pickerOpens += 1; + return super.listRewindTargets(); + } + + /** How many times the rewind picker opened; picker renders share labels with + * the transcript, so scrollback text alone cannot tell two openings apart. */ + get pickerOpens(): number { + return this.#pickerOpens; + } + + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + this.#rewindSeq += 1; + this.sessionId = `session-branch-${this.#rewindSeq}`; + return { + ...result, + summary: fakeSessionSummary(this.sessionId), + quotes: this.rewindQuotes(this.#rewindSeq), + }; + } +} + +/** + * A quote-only queued entry: the Host's retraction answers with quotes and no + * text, so the editor stays empty while the staging takes the quotes — the + * exact shape the close guard must not mistake for "nothing to keep" (#5265 + * review). + */ +class QuoteOnlyRetractingSideDriver extends RetractingSideConversationDriver { + quoteOnlyRetraction: QuoteRef[] = []; + + override async retractQueued(): Promise { + const retracted = await super.retractQueued(); + if (this.quoteOnlyRetraction.length === 0) return retracted; + return { text: '', messageIds: retracted.messageIds, quotes: [...this.quoteOnlyRetraction] }; + } +} + /** * Holds `busy` from underneath an open picker: publishSuccessor-style, a * Host-started turn begins (and blocks on `turnGate`) while the rewind picker * is already open, so a selection lands on runControl's busy early return. */ -class BusyAfterPickerOpenDriver extends RewindDriver { +class BusyAfterPickerOpenDriver extends QuotedRewindDriver { readonly turnGate = deferred(); #startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index a849e63b28..ba55788799 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2108,6 +2108,7 @@ describe('Runtime Host Maka Session driver', () => { assert.deepEqual(await driver.retractQueued!(), { text: 'Later', messageIds: ['message-1'], + quotes: [], }); assert.deepEqual( connection.requests.filter( @@ -2137,6 +2138,36 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('returns retracted quotes so the TUI can restage them', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('retract-1'), + }); + await driver.switchSession('session-1'); + + // A queued submit rode a QuoteRef: the Host stores the full + // MessageContent, so the retraction must hand the quotes back instead of + // letting them vanish with the queue row (#5109 review). + await driver.submitMessage!('Read this excerpt', { + messageId: 'message-1', + placement: 'next_turn', + quotes: [{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-9' }], + }); + const retracted = await driver.retractQueued!(); + assert.deepEqual(retracted.messageIds, ['message-1']); + assert.deepEqual( + retracted.quotes, + [{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-9' }], + 'the quotes a queued submit rode in on come back with the retraction', + ); + }); + test('submits an idle message under the caller-owned stable identity', async () => { const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); const connection = new FakeConnection([subscription]); @@ -2443,11 +2474,11 @@ describe('Runtime Host Maka Session driver', () => { ); }); - test('fails rewind closed when the selected turn carries structured content', async () => { - // A rewind that refills only the human-facing text would silently drop - // the selected turn's quotes/attachments from the replacement submit — - // fail closed with a precise notice instead until the TUI can carry - // them (#5109). + test('hands rewound quotes back verbatim and still refuses attachments', async () => { + // Rewinding a quoted turn must return the turn's QuoteRefs so the TUI can + // stage them into the replacement submit (#5109): refilling only the + // human-facing text would silently drop them. Attachments and directory + // references stay fail-closed — the TUI cannot re-attach files. const attachment = { kind: 'image', name: 'chart.png', @@ -2470,32 +2501,29 @@ describe('Runtime Host Maka Session driver', () => { directoryReferences: [{ hostId: 'host-1', path: tmpdir() }], }, ]; - const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve(messages)); - const current = new FakeSubscription( - continuitySnapshot(), - Promise.resolve(messages), - 'subscription-2', - ); - const direct = new FakeSubscription( - continuitySnapshot(), - Promise.resolve(messages), - 'subscription-3', - ); - const fourth = new FakeSubscription( - continuitySnapshot(), - Promise.resolve(messages), - 'subscription-4', + const subscriptions = Array.from( + { length: 7 }, + (_, index) => + new FakeSubscription( + continuitySnapshot(), + Promise.resolve(messages), + `subscription-${index + 1}`, + ), ); - const connection = new FakeConnection([attached, current, direct, fourth]); + const connection = new FakeConnection(subscriptions); // A directory that exists on every platform: the driver rejects a session // whose cwd has disappeared, and the catalog projection's default `/tmp` - // only exists on POSIX. + // only exists on POSIX. The committed rewind branches into a new session, + // so every catalog lookup on the way — setup, each attempt, and the + // post-commit switch — needs the existing directory. const existingCwd = tmpdir(); - connection.sessionQueries.push( - sessionProjection({ - workspace: { target: { kind: 'host_path', path: existingCwd }, hostCwd: existingCwd }, - }), - ); + for (let index = 0; index < 5; index += 1) { + connection.sessionQueries.push( + sessionProjection({ + workspace: { target: { kind: 'host_path', path: existingCwd }, hostCwd: existingCwd }, + }), + ); + } const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: existingCwd, @@ -2506,15 +2534,11 @@ describe('Runtime Host Maka Session driver', () => { await driver.switchSession('session-1'); await assert.rejects( - driver.rewindToTurn('turn-quoted'), - /carries structured context the TUI cannot restore/, - ); - await assert.rejects( - driver.rewindToTurn('turn-attached'), - /carries structured context the TUI cannot restore/, - ); - await assert.rejects( - driver.rewindToTurn('turn-directory'), + driver.rewindToTurn('turn-attached').catch((error: unknown) => { + const code = (error as { code?: unknown }).code; + assert.equal(code, 'rewind_unsupported_attachments'); + throw error; + }), /carries structured context the TUI cannot restore/, ); await assert.rejects( @@ -2523,12 +2547,54 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(code, 'rewind_unsupported_directory_references'); throw error; }), + /carries structured context the TUI cannot restore/, ); assert.equal( connection.requests.some(({ operation }) => operation === 'session.revision.create'), false, 'no revision is created for content the TUI cannot carry', ); + + const result = await driver.rewindToTurn('turn-quoted'); + assert.deepEqual(result.quotes, [{ text: 'a large pasted excerpt' }]); + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.revision.create'), + true, + 'the quoted turn branches through a revision copy', + ); + }); + + test('carries staged quotes on the replacement submit', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + // The catalog projection's default `/tmp` only exists on POSIX. + connection.sessionQueries.push( + sessionProjection({ + workspace: { target: { kind: 'host_path', path: tmpdir() }, hostCwd: tmpdir() }, + }), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: tmpdir(), + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + await driver.submitMessage!('Read this excerpt', { + messageId: 'message-1', + placement: 'current_turn', + quotes: [{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-9' }], + }); + const submit = connection.requests.find(({ operation }) => operation === 'turn.message.submit'); + assert.ok(submit, 'the submit request was recorded'); + const content = (submit.input as { content: { quotes?: unknown } }).content; + assert.deepEqual( + content.quotes, + [{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-9' }], + 'the driver forwards the staged QuoteRefs verbatim', + ); }); test('opens a hidden side copy at the latest completed Turn and removes it on close', async (t) => { @@ -3426,6 +3492,17 @@ class FakeConnection { if (operation === 'turn.stop') { return {} as OperationOutput; } + if (operation === 'session.revision.create') { + const revision = input as OperationInput<'session.revision.create'>; + return { + kind: 'committed', + session: sessionProjection({ + id: revision.targetSessionId, + branchOfTurnId: revision.sourceTurnId, + workspace: { target: { kind: 'host_path', path: tmpdir() }, hostCwd: tmpdir() }, + }), + } as OperationOutput; + } if (operation === 'turn.resume.start') { const resumeInput = input as OperationInput<'turn.resume.start'>; return { @@ -3466,18 +3543,37 @@ class FakeConnection { ); })() : operation === 'queue.retract' - ? { - hostEpoch: 'host-1', - queueRevision: 3, - retracted: [ - { - entryId: 'entry-1', - messageId: 'message-1', - content: { text: 'Later' }, - placement: 'next_turn', - }, - ], - } + ? (() => { + // The Host returns the stored full MessageContent for every + // retracted queue entry, quotes included: echo what the + // recorded submit carried for this message. + const submits = this.requests.filter( + (request) => request.operation === 'turn.message.submit', + ); + let submitted: { content: { text: string } } | undefined; + for (let index = submits.length - 1; index >= 0; index -= 1) { + const candidate = submits[index].input as { + messageId?: string; + content: { text: string }; + }; + if (candidate.messageId === 'message-1') { + submitted = candidate; + break; + } + } + return { + hostEpoch: 'host-1', + queueRevision: 3, + retracted: [ + { + entryId: 'entry-1', + messageId: 'message-1', + content: submitted?.content ?? { text: 'Later' }, + placement: 'next_turn', + }, + ], + }; + })() : operation === 'interaction.answer' ? (input as OperationInput<'interaction.answer'>).answer.kind === 'form' ? { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 5d5199b222..0ef0bf71ec 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -172,7 +172,14 @@ const LIVE_TOOL_BUFFER_MAX_CHARS = 64 * 1024; const LIVE_TOOL_BUFFER_MAX_CHUNKS = 512; export type MakaPiTranscriptEntry = - | { kind: 'user'; messageId: string; text: string; transient?: boolean } + | { + kind: 'user'; + messageId: string; + text: string; + transient?: boolean; + /** Quote count the message rode in on; rendered as a trace line. */ + quotes?: number; + } | { kind: 'legacy_automation'; text: string } | { kind: 'goal_continuation'; text: string } | { kind: 'assistant'; messageId: string; text: string } @@ -232,6 +239,8 @@ export interface MakaPiTranscriptMetadata { * terminal goals leave no segment, matching the desktop chip. */ goal?: GoalProjection | null; + /** QuoteRefs staged by a rewind, riding the next submit (#5109). */ + stagedQuoteCount?: number; sideConversation?: { view: 'parent' | 'side'; parentStatus?: MakaSideConversationParentStatus; @@ -277,12 +286,17 @@ export function appendUserPrompt( text: string, messageId: string, transient = false, + quotes = 0, ): void { const entry = { kind: 'user', messageId, text, ...(transient ? { transient: true } : {}), + // The quote trace renders on the live row too: a quote-only submit stores + // no text, so without the count the row is an empty prompt until the Host + // echoes the message back (#5265 review). + ...(quotes > 0 ? { quotes } : {}), } as const; const existingIndex = state.entries.findIndex( (candidate) => candidate.kind === 'user' && candidate.messageId === messageId, @@ -978,6 +992,9 @@ export function applyMakaSessionEventToTranscript( case 'steering_message': // A user interjection injected mid-turn; render it in place as a user turn. + // The event replaces this message's live transient row, so the quote + // count the row was showing must come along or a quote-only steer loses + // its trace the moment the Host acknowledges it (#5265 review). appendUserPrompt( state, event.content.displayText ?? event.content.text, @@ -988,6 +1005,7 @@ export function applyMakaSessionEventToTranscript( entry.messageId === event.messageId && entry.transient === true, ), + event.content.quotes?.length ?? 0, ); break; @@ -1087,10 +1105,12 @@ function storedMessagesToTranscriptEntries( } else if (message.origin?.kind === 'goal') { entries.push({ kind: 'goal_continuation', text: message.displayText ?? message.text }); } else { + const quoteCount = message.quotes?.length; entries.push({ kind: 'user', messageId: message.id, text: message.displayText ?? message.text, + ...(quoteCount ? { quotes: quoteCount } : {}), }); } break; @@ -1568,8 +1588,17 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) const contentWidth = Math.max(1, width - 2); const lines = (() => { switch (entry.kind) { - case 'user': - return renderUserBlock(entry.text, contentWidth); + case 'user': { + const lines = renderUserBlock(entry.text, contentWidth); + // A quote-only submit stores no text: without this trace the sent + // context would leave no row at all — an answer to an invisible + // prompt (#5109 review). The wording stays neutral: every quoted + // user message carries this field, desktop plain quotes included, + // not just a rewind's restaged ones. + if (entry.quotes === undefined) return lines; + const hint = `· ${entry.quotes} quote${entry.quotes === 1 ? '' : 's'}`; + return [...lines, ...renderUserBlock(hint, contentWidth)]; + } case 'legacy_automation': return renderLegacyAutomationBlock(entry.text, contentWidth); case 'goal_continuation': @@ -1696,6 +1725,12 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width } else if (metadata.orchestrationMode === 'graph') { parts.push({ text: ansi.accent('graph'), dropRank: 4 }); } + // Staged quotes ride the next submit; the accent salience mirrors the + // goal segment — a pending attachment to the next message the user must + // not miss. /quotes clear is how it leaves. + if (metadata.stagedQuoteCount) { + parts.push({ text: ansi.accent(`quotes:${metadata.stagedQuoteCount}`), dropRank: 3 }); + } // An autonomous goal burns tokens between prompts; it must never be // invisible. Terminal goals show nothing (the desktop chip hides them too). if (metadata.goal && isLiveGoalStatus(metadata.goal.status)) { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 89e44befdd..bac47a610a 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -94,8 +94,10 @@ import { MakaSessionCatalogIncompleteError, type MakaAttachedSessionTurn, type MakaPreparedSessionTurn, + type MakaRetractedMessages, type MakaSessionDriver, type MakaSessionListOptions, + type MakaSessionRewindResult, type MakaSideConversationParentStatus, type MakaSessionSwitchResult, type SessionResumeAvailability, @@ -412,7 +414,12 @@ interface TuiRewindCopy { readonly doneKeptDraft: string; readonly noTargets: string; readonly busy: string; - readonly unsupportedQuotes: string; + readonly quotesRestored: string; + readonly quotesRestoredUnknown: string; + readonly quotesCleared: string; + readonly quotesNone: string; + readonly quotesUsage: string; + readonly quotesListHeading: string; readonly unsupportedAttachments: string; readonly unsupportedDirectoryReferences: string; readonly pickerHint: string; @@ -652,6 +659,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { readonly sideSessionId: string; parentDraft: string; sideDraft: string; + // Quotes recovered by a retraction ride the draft they were restored + // into: parked here while the other view is active, staged back when + // the view returns (#5265 review). + parentQuotes: StagedQuoteRefs; + sideQuotes: StagedQuoteRefs; parentStatus?: MakaSideConversationParentStatus; stopParentObserver?: () => Promise; } @@ -694,6 +706,67 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { | { readonly kind: 'external'; readonly turn: MakaAttachedSessionTurn }; let pendingAttachedTurn: AttachedTurnContext | undefined; const resolvedInteractionIds = new Set(); + // Quotes restored by a rewind (#5109) wait here for the next submit. The + // staging is keyed to the session it was restored in, so it only renders + // while that session is active, and every session change clears it + // outright (applySwitchResult) — a switch must not be able to resurrect + // the quotes into a later submit unnoticed; a refused or failed submit + // keeps them for the retry. + // + // Retraction-restored quotes (#5265 review) are the exception: they come + // back attached to the retracted message's text, whose only remaining copy + // is the editor/draft the restore handed it to, so they follow that text — + // into the staging (while it is in the editor) or into the side + // conversation's draft slots (when the text is parked there) — instead of + // dying on a switch. followDraft marks that lane; the session key still + // gates turn-staged quotes exactly as #5109 left it. + type StagedQuoteRefs = NonNullable; + let stagedRewindQuotes: StagedQuoteRefs = []; + let stagedQuotesSessionId: string | null = null; + let stagedQuotesFollowDraft = false; + let stagedGeneration = 0; + const effectiveStagedQuotes = () => + stagedQuotesFollowDraft || + (stagedQuotesSessionId !== null && stagedQuotesSessionId === input.driver.getSessionId()) + ? stagedRewindQuotes + : []; + // Every write to the staging pair is a new generation. In-flight submits + // capture the generation at dispatch and only restage their quotes when no + // write has landed since, so a write that skips this setter would let a + // stale failure callback overwrite newer staging (#5109 review). + const setStagedQuotes = ( + quotes: StagedQuoteRefs, + sessionId: string | null, + followDraft = false, + ) => { + stagedRewindQuotes = quotes; + stagedQuotesSessionId = sessionId; + stagedQuotesFollowDraft = followDraft; + stagedGeneration += 1; + }; + const clearStagedQuotes = () => setStagedQuotes([], null); + // Quotes riding the recovered draft text move with it: taking them clears + // the staging (the text is leaving the editor for a draft slot), staging + // them back re-arms them for the view that owns the text (#5265 review). + const takeDraftQuotes = (): StagedQuoteRefs => { + if (!stagedQuotesFollowDraft || stagedRewindQuotes.length === 0) return []; + const quotes = stagedRewindQuotes; + clearStagedQuotes(); + return quotes; + }; + const stageDraftQuotes = (quotes: StagedQuoteRefs, sessionId: string) => { + if (quotes.length === 0) return; + setStagedQuotes(quotes, sessionId, true); + }; + // Some actions supersede a pending restoration without writing the staging + // pair, because the staging is already empty: an ordinary submit that + // carries no quotes, or an explicit `/quotes clear` that finds nothing. + // Both still advance the generation, so an in-flight submit's failure + // callback cannot re-arm quotes the conversation has moved past (#5109 + // review, second round). + const supersedePendingRestage = () => { + stagedGeneration += 1; + }; let startAttachedTurn: ((attached: AttachedTurnContext) => void) | undefined; const startPendingAttachedTurn = () => { if (busy || turnRunning) return; @@ -770,6 +843,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providerRetry: state.providerRetry, uiLocale: locale, goal: input.driver.getGoal?.() ?? null, + stagedQuoteCount: effectiveStagedQuotes().length, ...(sideConversation ? { sideConversation: { @@ -1255,8 +1329,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; // An in-flight retraction serializes navigation: a session switch waits - // for it, so the retracted text lands in the session it was asked for - // instead of being discarded after the driver re-keyed (#5109 review). + // for it, so the retracted text and quotes land in the session they were + // asked for instead of being discarded after the driver re-keyed (#5109 + // review). const pendingRetractionTasks = new Set>(); const trackRetraction = (task: Promise): void => { pendingRetractionTasks.add(task); @@ -1273,7 +1348,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // retraction asked for there starts after the switch's `settleRetractions` // drain yet still addresses the old Session — the Host removes the queued // entries while the driver re-keys, and the switched-session fence then - // discards the returned text. New retractions are blocked for + // discards the returned text and quotes. New retractions are blocked for // the entire switch instead (#5265 review). let sessionSwitchesInFlight = 0; const holdSwitchWindow = async (body: () => Promise): Promise => { @@ -1306,13 +1381,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // a mid-turn `/session` landing while enqueues or the Host call are in // flight re-keys the driver, and the abandoned retraction must neither // retract the new session's queue nor stop its running turn, nor land - // its text there (#5109 review). + // its text or quotes there (#5109 review). const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); if (input.driver.getSessionId() !== retractionSessionId) return; const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [], + quotes: [], }; if (input.driver.getSessionId() !== retractionSessionId) { return; @@ -1332,7 +1408,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // `busy`, so a prompt typed mid-switch goes back to the editor rather than // racing it. Exiting is never held back. const submitPrompt = (prompt: string) => { - if (!prompt.trim()) { + // Staged rewind quotes are the replacement content on their own: an empty + // text with quotes present is a meaningful quote-only submission (#5109). + if (!prompt.trim() && effectiveStagedQuotes().length === 0) { requestRender(); return; } @@ -1392,9 +1470,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (index >= 0) state.entries.splice(index, 1); }; - const acceptRetraction = (retracted: { text: string; messageIds: readonly string[] }) => { + const acceptRetraction = (retracted: MakaRetractedMessages) => { for (const messageId of retracted.messageIds) removeTransientUserMessage(messageId); refillEditorFromQueues(retracted.text); + // The Host returns the full MessageContent with a retraction: quotes that + // rode a queued or steered message come back with it and restage here, so + // the re-edited retry does not go out without them (#5109 review). They + // ride the restored draft text, so they follow it across switches and + // draft parking (#5265 review). + if (retracted.quotes.length > 0) { + setStagedQuotes(retracted.quotes, input.driver.getSessionId(), true); + } }; /** @@ -1409,18 +1495,71 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ) => { editor.addToHistory(text); const messageId = randomUUID(); - appendUserPrompt(state, text, messageId, true); + // The staged quotes must be read before the live row renders: a + // quote-only submit has no text, so the row's quote trace is the only + // thing distinguishing it from an empty prompt until the Host echoes the + // message back (#5265 review). + const staged = effectiveStagedQuotes(); + appendUserPrompt(state, text, messageId, true, staged.length); requestRender(); + // Quotes staged by a rewind (#5109) ride this message and only this one: + // the staging clears as the message dispatches, and a refusal or failure + // restages them for the retry. + const originSessionId = input.driver.getSessionId(); + // The lane the quotes were staged in must be read before the dispatch's + // own clear: clearStagedQuotes() resets the flag, so a read after it + // always records turn-staged and a failed submit's restage would hand the + // quotes to the next switch's staging clear (#5265 review). + const originFollowDraft = stagedQuotesFollowDraft; + if (staged.length > 0) clearStagedQuotes(); + else supersedePendingRestage(); + // The generation is read after the dispatch's own clear: the restore + // guard compares against the staging state this submit actually left + // behind, so an ordinary failure still passes while a Session switch, a + // newer rewind, or an explicit clear landing while the admission was in + // flight has since bumped it and must not inherit context meant for the + // original conversation (#5109 review). + const originGeneration = stagedGeneration; + const restageForRetry = (): boolean => { + if (!staged.length) return false; + if (input.driver.getSessionId() !== originSessionId) return false; + if (stagedGeneration !== originGeneration) return false; + setStagedQuotes(staged, originSessionId, originFollowDraft); + return true; + }; const task = input.driver - .submitMessage(text, { messageId, placement, ...options }) + .submitMessage(text, { + messageId, + placement, + ...options, + ...(staged.length > 0 ? { quotes: staged } : {}), + }) .then((result) => { // Runtime Host resolved the Skills this Message named and refused it. // Retire the row it belongs to and report the failure in its place. if (result?.disposition === 'blocked') { removeTransientUserMessage(messageId); + restageForRetry(); showSkillInvocation(result.skillInvocation); return; } + // A resolved-but-receipt-less submit is the real driver's outcome + // unknown path (`outcome_unknown`, or an interruption after the + // dispatch went out): admission cannot be proven either way. The + // dispatch already consumed the staging, so restage it — losing the + // user's explicit context to an unproven outcome is worse than a + // visible duplicate ride, which the status line surfaces and + // `/quotes clear` discards (#5109 review). + if (!result) { + if (restageForRetry()) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesRestoredUnknown, + }); + } + return; + } // It admitted them instead. The receipt says what was loaded and what // was dropped, and the submit answer is the only place it appears: the // Turn arrives through the started-Turn subscription, which carries @@ -1434,6 +1573,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // The Message never became anything, so its row goes with the failure // notice that replaces it. The text stays in editor history for a retry. removeTransientUserMessage(messageId); + restageForRetry(); reportError(error); }) .finally(() => { @@ -1446,7 +1586,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // step boundary. The Host alone decides whether it steers or starts a // successor Turn if the previous Turn settled during admission. const steerRunningTurn = (text: string) => { - if (!text.trim()) { + if (!text.trim() && effectiveStagedQuotes().length === 0) { requestRender(); return; } @@ -1457,7 +1597,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // reaches this — it stays the editor's completion trigger (key handler gate). const queueDraftForNextTurn = () => { const text = editor.getExpandedText().trim(); - if (!text) return; + if (!text && effectiveStagedQuotes().length === 0) return; editor.setText(''); submitMessage(text, 'next_turn'); }; @@ -1475,14 +1615,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const retractionTask = (async () => { // Same session fence as the interrupt path: a mid-turn `/session` that // lands while enqueues or the retraction are in flight must neither - // retract the new session's queue nor inherit the text of the session - // we left (#5109 review). + // retract the new session's queue nor inherit the quotes or the text + // of the session we left (#5109 review). const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); if (input.driver.getSessionId() !== retractionSessionId) return; const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [], + quotes: [], }; if (input.driver.getSessionId() !== retractionSessionId) return; acceptRetraction(retracted); @@ -1931,6 +2072,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }: MakaSessionSwitchResult): Promise => { resetTranscriptViewer(); closeTodoOverlay(); + // Every session change invalidates turn-staged rewind quotes outright: + // keying the staging to its session only hides it while the user is + // elsewhere, and a silent resurrection on return would send context the + // user can no longer see (#5109 review). The rewind re-stages its own + // quotes after this returns. + // + // Retraction-restored quotes are the exception (#5265 review): they belong + // to the recovered text the editor is still holding across this switch, + // and the Host already removed the queue entries they came from — the + // staging here is the only copy. They stay live, keyed to wherever that + // text lives next (this session's editor, or the draft slot a side + // toggle/open parks it in right after this returns). + if (!stagedQuotesFollowDraft) clearStagedQuotes(); adoptSessionMetadata(summary, false); replaceTranscript(messages); syncInteractionOverlays(); @@ -1950,8 +2104,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // lands on the old one. const runSessionSwitch = async (sessionId: string, relocateCwd?: string) => { // A session switch waits for an in-flight retraction: the retracted text - // must land in the session it was asked for before the driver re-keys - // (#5109 review). + // and quotes must land in the session they were asked for before the + // driver re-keys (#5109 review). await settleRetractions(); resolvedInteractionIds.clear(); const result = await input.driver.switchSession( @@ -2126,18 +2280,25 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { else await switchSession(targetSessionId); if (sideConversation !== pair) return; // Capture after the switch: both switch paths drain a pending - // retraction first, and the drain may have restored text into the - // editor. Capturing before the wait would write that stale draft back - // over the recovery — side editor, parent editor, and Host queue all - // empty with no notice (#5265 review). + // retraction first, and the drain may have restored text (and its + // quotes) into the editor. Capturing before the wait would write that + // stale draft back over the recovery — side editor, parent editor, and + // Host queue all empty with no notice (#5265 review). const currentDraft = editor.getText(); + const currentQuotes = takeDraftQuotes(); if (fromSide) { pair.sideDraft = currentDraft; + pair.sideQuotes = currentQuotes; editor.setText(pair.parentDraft); + stageDraftQuotes(pair.parentQuotes, pair.parentSessionId); + pair.parentQuotes = []; await stopSideParentObserver(pair); } else { pair.parentDraft = currentDraft; + pair.parentQuotes = currentQuotes; editor.setText(pair.sideDraft); + stageDraftQuotes(pair.sideQuotes, pair.sideSessionId); + pair.sideQuotes = []; await startSideParentObserver(pair); } requestRender(); @@ -2230,12 +2391,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { parentSessionId: result.parentSessionId, sideSessionId: result.sideSessionId, parentDraft: editor.getText(), - // The drain may have restored the parent's recovered text into the - // editor before the re-key; it is captured here, and the editor - // switches to the side view's own (empty) draft — pressing Enter here - // must not resubmit the parent's message inside the side conversation - // (#5265 review). + // The drain may have restored the parent's recovered text — and the + // quotes riding it — into the editor before the re-key; both are + // captured here, and the editor switches to the side view's own + // (empty) draft — pressing Enter here must not resubmit the parent's + // message inside the side conversation (#5265 review). + parentQuotes: takeDraftQuotes(), sideDraft: '', + sideQuotes: [], }; editor.setText(sideConversation.sideDraft); await startSideParentObserver(sideConversation); @@ -2277,18 +2440,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (!pair || !input.driver.closeSideConversation) return; // Same drain as the open: a retraction asked while the side Session was // active must land while the driver still points at it — past the re-key - // onto the parent, the close's session fence discards the text the Host - // already removed from the side queue (#5265 review). + // onto the parent, the close's session fence discards the text and quotes + // the Host already removed from the side queue (#5265 review). await settleRetractions(); - // The close is only admitted from an empty editor: recovered text in the - // editor is the only copy left, and closing here would overwrite it with - // the parent draft below or clear it on the switch. Aborting keeps it - // visible; a later Ctrl+C clears it like any draft (#5265 review). - if (editor.getText().length > 0) { + // The close is only admitted from an empty editor with nothing staged: + // recovered text in the editor (or staged quotes with no text — a + // quote-only retraction) is the only copy left, and closing here would + // overwrite it with the parent draft below or clear it on the switch. + // Aborting keeps it visible; a later Ctrl+C clears it like any draft + // (#5265 review). + const keptDraft = editor.getText().length > 0; + const keptQuotes = effectiveStagedQuotes().length > 0; + if (keptDraft || keptQuotes) { state.entries.push({ kind: 'notice', level: 'info', - text: 'Side conversation kept open — the editor draft was kept.', + text: keptDraft + ? 'Side conversation kept open — the editor draft was kept.' + : 'Side conversation kept open — the staged quotes were kept.', }); requestRender(); return; @@ -2310,6 +2479,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { : liveDraft : pair.parentDraft, ); + // The parent view's own quotes come back with its draft, still keyed to + // the session their text lives in (#5265 review). + stageDraftQuotes(pair.parentQuotes, pair.parentSessionId); await stopSideParentObserver(pair); sideConversation = undefined; if (result.cleanup === 'pending') { @@ -2371,16 +2543,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // driver's English fallback. const code = (error as { code?: unknown })?.code; if ( - code === 'rewind_unsupported_quotes' || code === 'rewind_unsupported_attachments' || code === 'rewind_unsupported_directory_references' ) { const localized = - code === 'rewind_unsupported_quotes' - ? TUI_REWIND_COPY[locale].unsupportedQuotes - : code === 'rewind_unsupported_attachments' - ? TUI_REWIND_COPY[locale].unsupportedAttachments - : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; + code === 'rewind_unsupported_attachments' + ? TUI_REWIND_COPY[locale].unsupportedAttachments + : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; throw new Error(localized); } throw error; @@ -2389,6 +2558,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return rewind; }); await discardCurrentSidePair(); + // The rewound turn's own quotes replace anything staged — including + // draft quotes a drained retraction restored: replacement, not + // accumulation (#5109, #5265 review). When the branch carries no quotes + // of its own, retraction-restored quotes stay staged and keep riding + // the recovered text, which the rewind preserved in the editor (#5265 + // review). + if (result.quotes?.length) { + clearStagedQuotes(); + setStagedQuotes(result.quotes, input.driver.getSessionId()); + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesRestored, + }); + } // Record the discarded turn's prompt in the editor history before // deciding on the refill: prompts submitted in this TUI process are // already there (addToHistory dedupes consecutive duplicates), but a @@ -4967,6 +5151,66 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void runControl(resumeSession); }, }, + quotes: { + description: primaryGuidance.commands.quotes, + // Composer-side staging only: listing or clearing it never touches the + // running Turn, so it routes through mid-turn like other local views. + midTurn: 'local', + run: (parts: string[]) => { + if (parts.length === 2 && parts[1] === 'clear') { + // Nothing staged (or the staged quotes already left on an in-flight + // submit): say so instead of claiming a discard that did nothing. + // The explicit intent still supersedes an in-flight submit's + // pending restoration. + if (effectiveStagedQuotes().length === 0) { + supersedePendingRestage(); + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesNone, + }); + requestRender(); + return; + } + clearStagedQuotes(); + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesCleared, + }); + } else if (parts.length === 1) { + const staged = effectiveStagedQuotes(); + if (staged.length === 0) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesNone, + }); + } else { + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesListHeading, + }); + for (const quote of staged) { + const preview = quote.label ? `${quote.label}: ${quote.text}` : quote.text; + state.entries.push({ + kind: 'notice', + level: 'info', + text: ` · ${preview.slice(0, 120)}`, + }); + } + } + } else { + state.entries.push({ + kind: 'notice', + level: 'error', + text: TUI_REWIND_COPY[locale].quotesUsage, + }); + } + requestRender(); + }, + }, rewind: { description: primaryGuidance.commands.rewind, midTurn: 'refuse', diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 76eb70e255..398fd00b55 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -680,6 +680,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { content: { text: modelText, ...(modelText === text ? {} : { displayText: text }), + ...(options.quotes?.length ? { quotes: [...options.quotes] } : {}), }, placement: options.placement, ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), @@ -720,7 +721,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } async retractQueued(): Promise { - if (!this.#sessionId) return { text: '', messageIds: [] }; + if (!this.#sessionId) return { text: '', messageIds: [], quotes: [] }; const result = await this.#request('queue.retract', { originHostEpoch: this.#connection.hostEpoch, sessionId: this.#sessionId, @@ -729,6 +730,9 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return { text: result.retracted.map((entry) => entry.content.text).join('\n\n'), messageIds: result.retracted.map((entry) => entry.messageId), + // The Host returns the full MessageContent for every retracted entry: + // the quotes ride back so the runner can restage them (#5109 review). + quotes: result.retracted.flatMap((entry) => entry.content.quotes ?? []), }; } @@ -1000,21 +1004,20 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (promptMessage.origin) { throw new Error(`Cannot rewind to turn ${turnId}: Host-triggered prompts are read-only.`); } + // Attachments and directory references stay fail-closed: refilling only + // the human-facing text would silently drop them from the replacement + // submit (#5109), and the TUI cannot re-attach files. Quotes ride the + // result verbatim instead, so the TUI can stage them into the replacement + // submit. The machine code lets the runner render a localized notice + // naming the carrier; the message text is the depth-of-defence fallback + // and deliberately promises nothing about other surfaces. const unsupported = - (promptMessage.quotes?.length ?? 0) > 0 - ? 'rewind_unsupported_quotes' - : (promptMessage.attachments?.length ?? 0) > 0 - ? 'rewind_unsupported_attachments' - : (promptMessage.directoryReferences?.length ?? 0) > 0 - ? 'rewind_unsupported_directory_references' - : null; + (promptMessage.attachments?.length ?? 0) > 0 + ? 'rewind_unsupported_attachments' + : (promptMessage.directoryReferences?.length ?? 0) > 0 + ? 'rewind_unsupported_directory_references' + : null; if (unsupported) { - // Refilling only the human-facing text would silently drop the turn's - // structured context from the replacement submit (#5109). Fail closed - // until the TUI can carry it. The machine code lets the runner render - // a localized notice naming the carrier; the message text is the - // depth-of-defence fallback and deliberately promises nothing about - // other surfaces. const error = new Error( `Cannot rewind to turn ${turnId}: it carries structured context the TUI cannot restore into the replacement prompt.`, ) as Error & { code?: string }; @@ -1035,6 +1038,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return { ...(await this.switchSession(requireSession(result.session).id)), prompt: userFacingText(promptMessage), + ...(promptMessage.quotes?.length ? { quotes: promptMessage.quotes } : {}), }; } } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 7e7626fb2c..cc11a23a93 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -18,7 +18,12 @@ */ import { realpath } from 'node:fs/promises'; -import type { SessionEvent, ShellRunStateResult, ShellRunUpdate } from '@maka/core/events'; +import type { + SessionEvent, + QuoteRef, + ShellRunStateResult, + ShellRunUpdate, +} from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -67,6 +72,12 @@ export interface MakaSessionSwitchResult { export interface MakaSessionRewindResult extends MakaSessionSwitchResult { prompt: string; + /** + * The rewound turn's QuoteRefs when it carried any. A surface that can + * stage them must carry them into the replacement submit; refilling the + * prompt text alone would silently drop them (#5109). + */ + quotes?: readonly QuoteRef[]; } export interface MakaSideConversationOpenResult extends MakaSessionSwitchResult { @@ -114,11 +125,23 @@ export interface MakaSubmitMessageOptions { modelText?: string; /** Exact-Turn intent carried to Runtime Host, which decides how to admit it. */ turnOrchestration?: TurnOrchestration; + /** + * QuoteRefs submitted verbatim alongside the text — the rewound turn's + * restored context a surface stages for the replacement submit (#5109). + */ + quotes?: readonly QuoteRef[]; } export interface MakaRetractedMessages { text: string; messageIds: readonly string[]; + /** + * The queued messages' inline excerpts, returned verbatim by the Host's + * `queue.retract` — the full `MessageContent` comes back with the + * retraction, so a queued submit's quotes can restage instead of + * vanishing with the queue row (#5109 review). + */ + quotes: readonly QuoteRef[]; } /** diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index dcbe462fce..582d8005aa 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -1080,6 +1080,7 @@ export const TUI_COPY_RESOURCES = { new: 'Start a new session', permissions: 'Set session permissions', recap: 'One-sentence recap of the session so far', + quotes: 'Show or discard restored quotes staged by a rewind', rename: 'Rename current session', resume: 'Resume latest interrupted run at a safe boundary', rewind: 'Rewind to an earlier turn', @@ -1134,6 +1135,7 @@ export const TUI_COPY_RESOURCES = { new: '新建会话', permissions: '设置会话权限', recap: '用一句话总结当前会话', + quotes: '查看或丢弃回退暂存的恢复引用', rename: '重命名当前会话', resume: '从安全边界恢复最近一次中断的执行', rewind: '回退到较早的对话轮次', @@ -1188,6 +1190,7 @@ export const TUI_COPY_RESOURCES = { new: '建立會話', permissions: '設定會話權限', recap: '用一句話總結目前會話', + quotes: '查看或捨棄回退暫存的恢復引用', rename: '重新命名目前會話', resume: '從安全邊界恢復最近一次中斷的執行', rewind: '回退到較早的對話輪次', @@ -1229,8 +1232,14 @@ export const TUI_COPY_RESOURCES = { 'Rewound to before this turn (branched into a new task; the original task is kept). The input box already had unsent content and was left untouched; the turn’s prompt was saved to input history — press ↑ to recall it.', noTargets: 'No turns to rewind to.', busy: 'Cannot rewind: another action is in progress — wait for it to finish, or interrupt (Esc) and retry.', - unsupportedQuotes: - 'Cannot rewind to this turn: it carries quoted excerpts, and the TUI cannot restore those into the replacement prompt yet. Rewind to an earlier plain-text turn instead.', + quotesRestored: + 'The rewound turn carried quoted context. It is restored and will be submitted with your next message — run /quotes clear to discard it.', + quotesRestoredUnknown: + 'Submit outcome unknown; the staged quotes are restored and will ride your next message — run /quotes clear to discard.', + quotesCleared: 'Restored quotes discarded; the next message submits without them.', + quotesNone: 'No restored quotes are staged.', + quotesUsage: 'Usage: /quotes [clear]', + quotesListHeading: 'Staged quotes:', unsupportedAttachments: 'Cannot rewind to this turn: it carries attachments, and the TUI cannot restore those into the replacement prompt yet. Rewind to an earlier plain-text turn instead.', unsupportedDirectoryReferences: @@ -1247,8 +1256,14 @@ export const TUI_COPY_RESOURCES = { '已回退到该轮之前(分支为新任务,原任务保留)。输入框已有未发送内容,未覆盖;该轮 prompt 已存入输入历史,可按 ↑ 找回。', noTargets: '没有可回退的轮次。', busy: '无法回退:当前有正在进行的操作 — 请等待其完成,或中断(Esc)后重试。', - unsupportedQuotes: - '无法回退到这一轮:它携带引用摘录,TUI 暂时无法把它们还原进替换 prompt。请改为回退到更早的纯文本轮次。', + quotesRestored: + '回退的这一轮带有引用内容:已恢复,并将随你的下一条消息一起提交——用 /quotes clear 丢弃。', + quotesRestoredUnknown: + '发送结果未知:暂存的引用已恢复,将随你的下一条消息一起提交——用 /quotes clear 丢弃。', + quotesCleared: '已丢弃恢复的引用;下一条消息不再携带。', + quotesNone: '当前没有暂存的恢复引用。', + quotesUsage: '用法:/quotes [clear]', + quotesListHeading: '暂存的引用:', unsupportedAttachments: '无法回退到这一轮:它携带附件,TUI 暂时无法把它们还原进替换 prompt。请改为回退到更早的纯文本轮次。', unsupportedDirectoryReferences: @@ -1264,8 +1279,14 @@ export const TUI_COPY_RESOURCES = { '已回退到該輪之前(分支為新任務,原任務保留)。輸入框已有未傳送內容,未覆蓋;該輪 prompt 已存入輸入歷史,可按 ↑ 找回。', noTargets: '沒有可回退的輪次。', busy: '無法回退:目前有正在進行的操作 — 請等待完成,或中斷(Esc)後重試。', - unsupportedQuotes: - '無法回退到這一輪:它攜帶引用摘錄,TUI 暫時無法把它們還原進替換 prompt。請改為回退到更早的純文字輪次。', + quotesRestored: + '回退的這一輪帶有引用內容:已恢復,並將隨你的下一則訊息一併送出——用 /quotes clear 捨棄。', + quotesRestoredUnknown: + '傳送結果未知:暫存的引用已恢復,將隨你的下一則訊息一併送出——用 /quotes clear 捨棄。', + quotesCleared: '已捨棄恢復的引用;下一則訊息不再攜帶。', + quotesNone: '目前沒有暫存的恢復引用。', + quotesUsage: '用法:/quotes [clear]', + quotesListHeading: '暫存的引用:', unsupportedAttachments: '無法回退到這一輪:它攜帶附件,TUI 暫時無法把它們還原進替換 prompt。請改為回退到更早的純文字輪次。', unsupportedDirectoryReferences: diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index 48981f86b5..aa8c3cf394 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -41,6 +41,7 @@ export const SLASH_COMMAND_CATALOG = [ { id: 'move', session: 'required', surfaces: ['tui'] }, { id: 'new', session: 'none', surfaces: ['tui'] }, { id: 'permissions', session: 'required', surfaces: ['tui'] }, + { id: 'quotes', session: 'none', surfaces: ['tui'] }, { id: 'recap', session: 'required', surfaces: ['tui'] }, { id: 'rename', session: 'required', surfaces: ['tui'] }, { id: 'resume', session: 'required', surfaces: ['tui'] }, diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs index 9c214adf75..919f30f760 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -94,6 +94,7 @@ export const ALLOWED_VISIBLE_LITERALS = { 'Side conversations are unavailable on this runtime.', 'Side conversation opened.', 'Side conversation kept open — the editor draft was kept.', + 'Side conversation kept open — the staged quotes were kept.', 'Side conversation closed; cleanup will be retried on the next launch.', '↑↓ move · type to answer · Enter select · Esc unanswered · Ctrl+C stop', 'Other: type your answer…',