diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index f4e5b05064..7b95282b95 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 c6c553e3f0..50e4810e6a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -60,9 +60,11 @@ import type { MakaSessionSwitchResult, MakaSubmitMessageOptions, MakaTranscriptReplacementReason, + MakaRetractedMessages, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; +import type { QuoteRef } from '@maka/core/events'; import { skillInvocationBlockedMessage } from '../session-driver.js'; import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js'; import { listApiKeyOnboardableProviders, onboardingCreateTarget } from '../onboarding-catalog.js'; @@ -7602,6 +7604,1996 @@ Slug openai-work ]); }); + test('stages a rewound turn quotes into the replacement submit', async () => { + const terminal = new FakeTerminal(); + 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, + 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(() => 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')); + + // 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([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('restores a failed quote submit only to its originating session', async () => { + const terminal = new FakeTerminal(); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', '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); + + // A Session switch lands while the admission is still pending. + driver.switchSession('session-other'); + driver.hold(new Error('admission outcome unknown')); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('admission outcome unknown'), + ); + + // 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([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('restages a failed quote submit back onto the same session', async () => { + const terminal = new FakeTerminal(); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', '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 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')); + + 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('restages quotes when a submit resolves without a receipt (outcome unknown)', async () => { + const terminal = new FakeTerminal(); + const driver = new UnknownOutcomeSubmitDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', '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 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([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('a newer rewind displaces an in-flight quote submit restage', async () => { + const terminal = new FakeTerminal(); + const driver = new PerRewindQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', '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); + + // 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 waitFor(() => driver.pickerOpens === 2); + terminal.input('\r'); + 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, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('lets a quote-only rewind reach the submit path unchanged', async () => { + const terminal = new FakeTerminal(); + const driver = new HeldSubmitQuotedDriver( + [{ turnId: 'turn-1', label: 'first question' }], + [storedUserMessage('user-1', 'turn-1', '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'); + // 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 waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + 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' }, + ]); + + // 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([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('discards staged quotes only through the explicit /quotes clear', async () => { + const terminal = new FakeTerminal(); + 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, + 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(() => 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('/quotes'); + terminal.input('\r'); + 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', + ); + + 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('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' }, + ]); + + // Alt+Up 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;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // 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.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('restages the quoted message among several retracted queue entries', 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); + // 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); + + terminal.input('\x1b[1;3A'); // Alt+Up retracts both. + await waitFor(() => driver.retractCalls === 1); + // 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); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('does not retract the switched-to queue when enqueues settle after a 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); + + // Park a second enqueue inside the driver, then Alt+Up: 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;3A'); // Alt+Up: 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 Alt+Up 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;3A'); // Alt+Up + 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('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); + + // Alt+Up 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;3A'); // Alt+Up + 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;3A'); // Alt+Up + 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 Alt+Up 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 Alt+Up 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:'))); + + // Alt+Up 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;3A'); // Alt+Up + 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 Alt+Up 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 Alt+Up stays live throughout (#5265 + // review). + driver.openGate = deferred(); + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))); + + // Alt+Up 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;3A'); // Alt+Up + 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 Alt+Up 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:'))); + + // Alt+Up 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;3A'); // Alt+Up + 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 Alt+Up 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;3A'); // Alt+Up + 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: + // Alt+Up 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;3A'); // Alt+Up + 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 Alt+Up 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;3A'); // Alt+Up + 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); + + // The Alt+Up 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;3A'); // Alt+Up + 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( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must survive the rewind instead of being discarded by the branch fence', + ); + + // 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'); + }), + ]); + } + }); + + 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;3A'); // Alt+Up + 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;3A'); // Alt+Up + 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;3A'); // Alt+Up + 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'); + }), + ]); + } + }); + + 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')); + + 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;3A'); // Alt+Up + 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'); + }), + ]); + } + }); + + test('captures the draft a retraction restores while a side toggle waits it out', 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('/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); + + // Ctrl+/ while the retraction is in flight: the toggle waits the + // retraction out and must capture the draft AFTER the drain restored it. + // Capturing before the wait writes the stale empty draft back over the + // recovered text — side editor, parent editor, and Host queue all end up + // empty with no notice (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('\x1f'); // Ctrl+/ — toggle to the parent + 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 toggle parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-1'); + // Toggle back to the side view: its draft must hold the recovered text. + terminal.input('\x1f'); // Ctrl+/ — toggle back to the side + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor( + () => editorInputText(terminal)?.includes('side follow-up') === true, + 'the text the retraction restored during the toggle must survive in the side draft', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('keeps text typed while a side conversation close is in flight', 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('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The close only disables submit for its window: typing still lands in + // the editor while the driver re-keys back to the parent. That live + // input wins over the parent draft — the unconditional + // setText(parentDraft) after the await threw it away (#5265 review). + driver.closeGate = deferred(); + terminal.input('\x03'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))); + terminal.input('typed while closing'); + driver.closeGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-1'); + assert.equal(driver.closedSides.length, 1); + await waitFor( + () => editorInputText(terminal)?.includes('typed while closing') === true, + 'text typed during the close window must survive the parent draft restore', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + 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;3A'); // Alt+Up + 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([ + { 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); + + // The close is admitted against an empty editor, then parks on its + // drain. Nothing is queued, so the retraction will restore nothing — + // the text in the editor when the drain settles is the user's own + // typing from the wait window, and the kept-open notice must not + // attribute it to a retraction (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up — queue is empty, call parks on the gate + await waitFor(() => driver.retractCalls === 1); + terminal.input('\x03'); + terminal.input('my own note'); + driver.retractGate.resolve(); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Side conversation kept open — the editor draft was kept.', + ), + ); + assert.equal( + plainTerminalOutput(terminal.output()).includes('retracted message'), + false, + 'the notice must not attribute a user-typed draft to a retraction', + ); + assert.equal(driver.closedSides.length, 0); + assert.equal(driver.getSessionId(), 'side-1'); + assert.ok( + editorInputText(terminal)?.includes('my own note') === true, + 'the user draft stays in the editor', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + test('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -11304,7 +13296,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'); @@ -11313,7 +13305,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 @@ -12887,12 +14879,400 @@ class DeferredRewindDriver extends RewindDriver { } } +/** + * 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 QuotedRewindDriver extends RewindDriver { + readonly submittedQuotes: Array = []; + + 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' }], + }; + } + + 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; + return () => { + if (this.#startedTurnListener === listener) this.#startedTurnListener = undefined; + }; + } + + startBlockingTurn(): void { + const gate = this.turnGate; + this.#startedTurnListener?.({ + sessionId: this.getSessionId()!, + turnId: 'turn-host', + messages: [ + storedUserMessage('user-host', 'turn-host', 'host question'), + storedAssistantMessage('assistant-host', 'turn-host', 'host answer'), + ], + summary: fakeSessionSummary(this.getSessionId()!), + events: (async function* () { + await gate.promise; + yield { + type: 'complete', + id: 'complete-host', + turnId: 'turn-host', + ts: 3, + stopReason: 'end_turn', + } satisfies SessionEvent; + })(), + }); + } + + 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, + quotes: options.quotes ?? [], + }); + if (this.enqueueGate) { + const gate = this.enqueueGate; + return gate.promise.then(() => super.submitMessage(text, options)); + } + } + return super.submitMessage(text, options); + } + + override async stop(): Promise { + this.#stopCalls += 1; + } + + 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.retractedQuoteLoads.push(quotes); + this.eventLog.push(`retract-done:${this.sessionId}`); + return retracted; + } + + override async switchSession(sessionId: string): Promise { + // The base fake leaves `sessionId` alone; a mid-turn switch must move the + // driver's session for the retraction-fence scenario to be reachable. + this.eventLog.push(`switch-start:${sessionId}`); + // The real driver spends several asynchronous calls (stopping user + // commands, opening the target Session channel) before it re-keys; the + // gate holds a test inside that in-progress switch window. + if (this.switchGate) await this.switchGate.promise; + this.eventLog.push(`switch:${sessionId}`); + this.sessionId = sessionId; + return super.switchSession(sessionId); + } +} + +/** + * 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 RetractingQuotesDriver { + openGate: ReturnType> | undefined = undefined; + closeGate: ReturnType> | undefined = undefined; + #parentStatusListener: + | ((status: MakaSideConversationParentStatus | undefined) => void) + | undefined; + readonly closedSides: Array<{ sideSessionId: string; parentSessionId: string }> = []; + + async openSideConversation() { + const parentSessionId = this.sessionId; + this.eventLog.push(`open-start:${parentSessionId}`); + if (this.openGate) await this.openGate.promise; + this.eventLog.push('open:side-1'); + this.sessionId = 'side-1'; + return { + summary: { + ...fakeSessionSummary('side-1'), + labels: ['mode:side_conversation'], + parentSessionId, + branchOfTurnId: 'turn-settled', + }, + messages: [], + parentSessionId, + sideSessionId: 'side-1', + }; + } + + async closeSideConversation(sideSessionId: string, parentSessionId: string) { + this.eventLog.push(`close-start:${sideSessionId}`); + if (this.closeGate) await this.closeGate.promise; + this.eventLog.push(`close:${parentSessionId}`); + this.sessionId = parentSessionId; + this.closedSides.push({ sideSessionId, parentSessionId }); + return { ...switchResult(fakeSessionSummary(parentSessionId)), cleanup: 'removed' as const }; + } + + async observeSideConversationParent( + _parentSessionId: string, + listener: (status: MakaSideConversationParentStatus | undefined) => void, + ) { + this.#parentStatusListener = listener; + return async () => { + if (this.#parentStatusListener === listener) this.#parentStatusListener = undefined; + }; + } +} + +/** + * 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 5cc7acfdd8..6e122c96b5 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1789,6 +1789,7 @@ describe('Runtime Host Maka Session driver', () => { assert.deepEqual(await driver.retractQueued!(), { text: 'Later', messageIds: ['message-1'], + quotes: [], }); assert.deepEqual( connection.requests.filter( @@ -2124,11 +2125,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', @@ -2151,32 +2152,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, @@ -2187,15 +2185,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( @@ -2204,12 +2198,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) => { @@ -3090,6 +3126,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 { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index b72bc4e9db..62216adf2f 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; @@ -1087,10 +1096,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 +1579,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 +1716,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 ada04433b3..c9b9f08761 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -93,7 +93,9 @@ import { inspectSessionResumeAvailability, type MakaAttachedSessionTurn, type MakaPreparedSessionTurn, + type MakaRetractedMessages, type MakaSessionDriver, + type MakaSessionRewindResult, type MakaSideConversationParentStatus, type MakaSessionSwitchResult, } from './session-driver.js'; @@ -408,7 +410,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; @@ -646,6 +653,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; } @@ -688,6 +700,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; @@ -764,6 +837,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providerRetry: state.providerRetry, uiLocale: locale, goal: input.driver.getGoal?.() ?? null, + stagedQuoteCount: effectiveStagedQuotes().length, ...(sideConversation ? { sideConversation: { @@ -1248,6 +1322,37 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { await Promise.allSettled([...pendingEnqueueTasks]); } }; + // An in-flight retraction serializes navigation: a session switch waits + // 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); + void task.finally(() => pendingRetractionTasks.delete(task)); + }; + const settleRetractions = async (): Promise => { + while (pendingRetractionTasks.size > 0) { + await Promise.allSettled([...pendingRetractionTasks]); + } + }; + // A session switch re-keys the driver only partway through its work: the + // driver stops user commands and opens the target Session's channel before + // adopting the new id, and Alt+Up stays live through that whole window. A + // 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 and quotes. New retractions are blocked for + // the entire switch instead (#5265 review). + let sessionSwitchesInFlight = 0; + const holdSwitchWindow = async (body: () => Promise): Promise => { + sessionSwitchesInFlight += 1; + try { + return await body(); + } finally { + sessionSwitchesInFlight -= 1; + } + }; const requestTurnInterrupt = () => { // A detach in flight is not the running Turn's owner acting on it — the @@ -1265,9 +1370,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // for re-editing, while anything already consumed stays in the transcript. // Serializing these operations also preserves that ordering over a Host // connection where both calls are asynchronous. - void (async () => { + const retractionTask = (async () => { + // Fence the retraction and the stop to the session they were asked for: + // 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 or quotes there (#5109 review). + const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + if (input.driver.getSessionId() !== retractionSessionId) return; + const retracted = (await input.driver.retractQueued?.()) ?? { + text: '', + messageIds: [], + quotes: [], + }; + if (input.driver.getSessionId() !== retractionSessionId) { + return; + } acceptRetraction(retracted); requestRender(); await input.driver.stop(); @@ -1276,13 +1395,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.disableSubmit = false; reportError(error); }); + trackRetraction(retractionTask); }; // Open a fresh turn from a submitted prompt (idle path). Control actions hold // `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; } @@ -1342,9 +1464,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); + } }; /** @@ -1361,16 +1491,65 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const messageId = randomUUID(); appendUserPrompt(state, text, messageId, true); 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 staged = effectiveStagedQuotes(); + 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 @@ -1384,6 +1563,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(() => { @@ -1396,7 +1576,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; } @@ -1414,7 +1594,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // be queued onto it and no fresh turn may open — keep the draft. if (interruptRequested) return; const text = editor.getExpandedText().trim(); - if (!text) return; + if (!text && effectiveStagedQuotes().length === 0) return; editor.setText(''); if (!turnRunning) { submitPrompt(text); @@ -1426,12 +1606,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Alt+↑: take back every queued message from the Runtime Host, joined and // prepended to the current draft for re-editing. const retractQueuedMessages = () => { - void (async () => { + // A session switch blocks new retractions for its entire window: the + // switch's own settle drain only covers retractions asked for before it, + // and past that drain a retraction would still address the old Session + // while the driver is busy re-keying (#5265 review). Swallowing the + // keypress loses nothing: the entries stay queued and Alt+Up works once + // the switch lands. + if (sessionSwitchesInFlight > 0) return; + 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 quotes or the text + // of the session we left (#5109 review). + const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + if (input.driver.getSessionId() !== retractionSessionId) return; + const retracted = (await input.driver.retractQueued?.()) ?? { + text: '', + messageIds: [], + quotes: [], + }; + if (input.driver.getSessionId() !== retractionSessionId) return; acceptRetraction(retracted); requestRender(); })().catch(reportError); + trackRetraction(retractionTask); }; // Onboarding wizard (#1098 UX redesign): one overlay spans provider search, @@ -1874,6 +2073,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(); @@ -1891,7 +2103,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // The driver validates the durable cwd before adopting the resumed session. // A failure leaves the active session untouched and the next prompt still // lands on the old one. - const switchSession = async (sessionId: string, relocateCwd?: string) => { + const runSessionSwitch = async (sessionId: string, relocateCwd?: string) => { + // A session switch waits for an in-flight retraction: the retracted text + // 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( sessionId, @@ -1935,6 +2151,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } requestRender(); }; + // The whole switch — its settle drain, the driver's asynchronous re-key, + // and the adoption — runs inside the switch window so Alt+Up cannot send a + // retraction for the session being left (#5265 review). + const switchSession = (sessionId: string, relocateCwd?: string): Promise => + holdSwitchWindow(() => runSessionSwitch(sessionId, relocateCwd)); // Mid-turn `/session` switch-away (#3380): adopt another Session while a // Turn is still running on the current one. In Runtime Host mode the Turn is @@ -1944,7 +2165,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // tail unwinds through the superseded branch and releases busy/activity, // then either that tail or the startPendingAttachedTurn below starts the // freshly attached Turn, whichever observes an idle runner first. - const switchAwayMidTurn = async (sessionId: string) => { + const runMidTurnSwitch = async (sessionId: string) => { + // Same serialization as the idle switch: the in-flight retraction lands + // its payload in the session it was asked for first (#5109 review). + await settleRetractions(); resolvedInteractionIds.clear(); detaching = true; try { @@ -1988,6 +2212,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { startPendingAttachedTurn(); } }; + // Same window as the idle switch: the mid-turn detach also re-keys the + // driver through asynchronous work, so Alt+Up waits it out (#5265 review). + const switchAwayMidTurn = (sessionId: string): Promise => + holdSwitchWindow(() => runMidTurnSwitch(sessionId)); const stopSideParentObserver = async ( pair: NonNullable, @@ -2043,18 +2271,30 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (!pair || detaching || (busy && !turnRunning)) return; const fromSide = input.driver.getSessionId() === pair.sideSessionId; const targetSessionId = fromSide ? pair.parentSessionId : pair.sideSessionId; - const currentDraft = editor.getText(); const switchView = async () => { if (turnRunning) await switchAwayMidTurn(targetSessionId); 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 (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(); @@ -2117,6 +2357,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const previousActivity = currentActivityCompletion; let opened = false; const adopt = async () => { + // The window blocks only retractions asked inside it: one already in + // flight when the open started must drain here, while the driver still + // points at the parent — the Host removes the parent's queued entries as + // the retraction resolves, and a response landing after the re-key onto + // the side Session would be discarded by the side-session fence (#5265 + // review). + await settleRetractions(); const result = await input.driver.openSideConversation!(); if (turnRunning) turnEpoch += 1; await applySwitchResult(result); @@ -2124,8 +2371,16 @@ 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 — 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); opened = true; state.entries.push({ @@ -2135,11 +2390,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); requestRender(); }; + // Opening re-keys the driver onto the side Session through asynchronous + // work (the Host forks the parent and switches onto the fork), the same + // switchSession re-key path as `/session`, so both entry paths hold the + // switch window: a retraction asked inside it would address the parent + // while the Host removes its queued entries, and the side-session fence + // would then discard what the Host removed (#5265 review). if (turnRunning) { if (detaching) return; detaching = true; try { - await adopt(); + await holdSwitchWindow(adopt); } catch (error) { reportError(error); } finally { @@ -2147,22 +2408,60 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { startPendingAttachedTurn(); } } else { - await runControl(adopt); + await runControl(() => holdSwitchWindow(adopt)); } if (!opened || !prompt) return; await previousActivity?.catch(() => undefined); submitPrompt(prompt); }; - const closeSideConversation = async (): Promise => { + const runCloseSideConversation = async (): Promise => { const pair = sideConversation; 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 and quotes + // the Host already removed from the side queue (#5265 review). + await settleRetractions(); + // 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: keptDraft + ? 'Side conversation kept open — the editor draft was kept.' + : 'Side conversation kept open — the staged quotes were kept.', + }); + requestRender(); + return; + } const result = await input.driver.closeSideConversation( pair.sideSessionId, pair.parentSessionId, ); await applySwitchResult(result); - editor.setText(pair.parentDraft); + // The switch window only disabled submit: anything typed while the close + // was in flight is live user input and outranks the parked parent draft. + // Keep it — appending the parent draft below when both exist — instead of + // letting the restore silently drop it (#5265 review). + const liveDraft = editor.getText(); + editor.setText( + liveDraft.length > 0 + ? pair.parentDraft.length > 0 + ? `${liveDraft}\n\n${pair.parentDraft}` + : 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') { @@ -2174,6 +2473,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } requestRender(); }; + // Same window as the open: closing re-keys the driver back onto the parent + // Session through its own asynchronous switch, so Alt+Up waits it out + // (#5265 review). + const closeSideConversation = (): Promise => holdSwitchWindow(runCloseSideConversation); const interruptAndCloseSideConversation = (): void => { if (interruptRequested) return; const completion = currentActivityCompletion; @@ -2205,29 +2508,51 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push(pendingNotice); requestRender(); try { - const result = await input.driver.rewindToTurn(turnId).catch((error: unknown) => { - // The driver refuses rewind with a machine code when the selected - // turn carries structured context the TUI cannot restore (#5109). - // Render the localized catalog copy for that code instead of the - // 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' + const result = await holdSwitchWindow(async () => { + // Same switch window as /session: rewind re-keys the driver through + // its own asynchronous branch-and-switch, and a retraction crossing + // that window would address the session being left (#5265 review). + // That window blocks only retractions asked inside it — one already in + // flight drains here, before the branch re-keys onto a fresh Session + // whose fence would otherwise discard the response (#5265 review). + await settleRetractions(); + const rewind = await input.driver.rewindToTurn(turnId).catch((error: unknown) => { + // The driver refuses rewind with a machine code when the selected + // turn carries structured context the TUI cannot restore (#5109). + // Render the localized catalog copy for that code instead of the + // driver's English fallback. + const code = (error as { code?: unknown })?.code; + if ( + code === 'rewind_unsupported_attachments' || + code === 'rewind_unsupported_directory_references' + ) { + const localized = + code === 'rewind_unsupported_attachments' ? TUI_REWIND_COPY[locale].unsupportedAttachments : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; - throw new Error(localized); - } - throw error; + throw new Error(localized); + } + throw error; + }); + await applySwitchResult(rewind); + return rewind; }); - await applySwitchResult(result); 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 @@ -4594,6 +4919,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 98c84abe69..ef7c0f60ab 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -572,6 +572,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 } : {}), @@ -612,7 +613,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, @@ -621,6 +622,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 ?? []), }; } @@ -892,21 +896,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 }; @@ -927,6 +930,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 30872b5bdf..b0affa7200 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 49ba7084f9..adcf62fa77 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -1063,6 +1063,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', @@ -1117,6 +1118,7 @@ export const TUI_COPY_RESOURCES = { new: '新建会话', permissions: '设置会话权限', recap: '用一句话总结当前会话', + quotes: '查看或丢弃回退暂存的恢复引用', rename: '重命名当前会话', resume: '从安全边界恢复最近一次中断的执行', rewind: '回退到较早的对话轮次', @@ -1171,6 +1173,7 @@ export const TUI_COPY_RESOURCES = { new: '建立會話', permissions: '設定會話權限', recap: '用一句話總結目前會話', + quotes: '查看或捨棄回退暫存的恢復引用', rename: '重新命名目前會話', resume: '從安全邊界恢復最近一次中斷的執行', rewind: '回退到較早的對話輪次', @@ -1212,8 +1215,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: @@ -1230,8 +1239,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: @@ -1247,8 +1262,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 6e054d2c62..05ae1490bc 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -93,6 +93,8 @@ export const ALLOWED_VISIBLE_LITERALS = { 'Close the current side conversation before opening another.', '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…',