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 3e57355734..61a266ae83 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7602,6 +7602,512 @@ 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('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -12858,12 +13364,191 @@ 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: '' }; + } +} + /** * 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 937e4b66be..894947293f 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2124,11 +2124,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 +2151,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 +2184,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 +2197,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) => { @@ -2988,6 +3023,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; + } const turnInput = input as { sessionId?: string; turnId?: string; 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 1aeafd31dd..775db5d309 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -94,6 +94,7 @@ import { type MakaAttachedSessionTurn, type MakaPreparedSessionTurn, type MakaSessionDriver, + type MakaSessionRewindResult, type MakaSideConversationParentStatus, type MakaSessionSwitchResult, } from './session-driver.js'; @@ -408,7 +409,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; @@ -688,6 +694,41 @@ 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. + let stagedRewindQuotes: NonNullable = []; + let stagedQuotesSessionId: string | null = null; + let stagedGeneration = 0; + const effectiveStagedQuotes = () => + 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: NonNullable, + sessionId: string | null, + ) => { + stagedRewindQuotes = quotes; + stagedQuotesSessionId = sessionId; + stagedGeneration += 1; + }; + const clearStagedQuotes = () => setStagedQuotes([], null); + // 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 +805,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providerRetry: state.providerRetry, uiLocale: locale, goal: input.driver.getGoal?.() ?? null, + stagedQuoteCount: effectiveStagedQuotes().length, ...(sideConversation ? { sideConversation: { @@ -1282,7 +1324,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // `busy`, so a prompt typed mid-switch goes back to the editor rather than // racing it. Exiting is never held back. const submitPrompt = (prompt: string) => { - if (!prompt.trim()) { + // Staged rewind quotes are the replacement content on their own: an empty + // text with quotes present is a meaningful quote-only submission (#5109). + if (!prompt.trim() && effectiveStagedQuotes().length === 0) { requestRender(); return; } @@ -1361,16 +1405,60 @@ 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(); + 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); + 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 +1472,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 +1485,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 +1503,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); @@ -1874,6 +1963,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }: MakaSessionSwitchResult): Promise => { resetTranscriptViewer(); closeTodoOverlay(); + // Every session change invalidates the 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. + clearStagedQuotes(); adoptSessionMetadata(summary, false); replaceTranscript(messages); syncInteractionOverlays(); @@ -2212,22 +2307,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // driver's English fallback. const code = (error as { code?: unknown })?.code; if ( - code === 'rewind_unsupported_quotes' || code === 'rewind_unsupported_attachments' || code === 'rewind_unsupported_directory_references' ) { const localized = - code === 'rewind_unsupported_quotes' - ? TUI_REWIND_COPY[locale].unsupportedQuotes - : code === 'rewind_unsupported_attachments' - ? TUI_REWIND_COPY[locale].unsupportedAttachments - : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; + code === 'rewind_unsupported_attachments' + ? TUI_REWIND_COPY[locale].unsupportedAttachments + : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; throw new Error(localized); } throw error; }); await applySwitchResult(result); await discardCurrentSidePair(); + // The branched session starts clean: any quotes staged for the previous + // session are gone, and the rewound turn's own quotes become the new + // staging (#5109). + clearStagedQuotes(); + if (result.quotes?.length) { + 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 +4698,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 0a53e498fb..ab1a7918fc 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -544,6 +544,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 } : {}), @@ -864,21 +865,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 }; @@ -899,6 +899,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 fbe4d7cd4f..94007c90c5 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -18,7 +18,13 @@ */ import { realpath } from 'node:fs/promises'; -import type { SessionEvent, ShellRunStateResult, ShellRunUpdate } from '@maka/core/events'; +import type { + SessionEvent, + QuoteRef, + ShellRunSnapshotResult, + 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'; @@ -66,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 { @@ -112,6 +124,11 @@ 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 { diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 881dc7421b..e460ddfabb 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -1054,6 +1054,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', @@ -1108,6 +1109,7 @@ export const TUI_COPY_RESOURCES = { new: '新建会话', permissions: '设置会话权限', recap: '用一句话总结当前会话', + quotes: '查看或丢弃回退暂存的恢复引用', rename: '重命名当前会话', resume: '从安全边界恢复最近一次中断的执行', rewind: '回退到较早的对话轮次', @@ -1162,6 +1164,7 @@ export const TUI_COPY_RESOURCES = { new: '建立會話', permissions: '設定會話權限', recap: '用一句話總結目前會話', + quotes: '查看或捨棄回退暫存的恢復引用', rename: '重新命名目前會話', resume: '從安全邊界恢復最近一次中斷的執行', rewind: '回退到較早的對話輪次', @@ -1203,8 +1206,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: @@ -1221,8 +1230,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: @@ -1238,8 +1253,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'] },