From 93a8fdf0ce60e90b0e1692509991c3290d1ec583 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 13 Sep 2026 23:27:05 +0800 Subject: [PATCH 01/10] fix(cli): stage a rewound turn's quotes into the replacement submit Rewinding to a turn that carried quotes used to fail closed with rewind_unsupported_quotes, because the TUI could only refill the human-facing text and the replacement submit would silently drop the turn's structured context (#5109). The runtime-host driver now returns the rewound turn's QuoteRefs verbatim and forwards quotes given to submitMessage through turn.message.submit, whose admission already accepts them (only session-context attachments are Host-owned). Attachments and directory references still fail closed, since the TUI cannot re-attach files. The TUI stages the restored quotes keyed to the branched session: the status line carries a quotes: segment while staging is live, bare /quotes lists the staged excerpts, /quotes clear discards them, and the first admitted submit consumes the staging while a refusal or failure restages it for the retry. Part of #5109 Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 137 ++++++++++++++++++ .../runtime-host-session-driver.test.ts | 116 ++++++++++----- packages/cli/src/pi-transcript.ts | 8 + packages/cli/src/pi-tui-runner.ts | 111 +++++++++++++- .../cli/src/runtime-host-session-driver.ts | 27 ++-- packages/cli/src/session-driver.ts | 18 ++- packages/cli/src/tui-copy-catalog.ts | 27 +++- packages/core/src/slash-command-catalog.ts | 1 + 8 files changed, 381 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 30fd041a16..5661220d4b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6777,6 +6777,117 @@ 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('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('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -11968,6 +12079,32 @@ 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); + } +} + /** * Holds `busy` from underneath an open picker: publishSuccessor-style, a * Host-started turn begins (and blocks on `turnGate`) while the rewind picker 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 7c066b7790..b6d2ea4e8e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2122,11 +2122,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', @@ -2149,32 +2149,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, @@ -2185,15 +2182,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( @@ -2202,12 +2195,52 @@ 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.deepEqual( + (submit?.input as { content: { quotes?: unknown } }).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) => { @@ -2994,6 +3027,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 73945632b8..f049694176 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -232,6 +232,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; @@ -1707,6 +1709,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 698f501ac7..34f7244e80 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'; @@ -371,7 +372,11 @@ interface TuiRewindCopy { readonly doneKeptDraft: string; readonly noTargets: string; readonly busy: string; - readonly unsupportedQuotes: string; + readonly quotesRestored: string; + readonly quotesCleared: string; + readonly quotesNone: string; + readonly quotesUsage: string; + readonly quotesListHeading: string; readonly unsupportedAttachments: string; readonly unsupportedDirectoryReferences: string; readonly pickerHint: string; @@ -599,6 +604,21 @@ 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 every switch path + // invalidates it without each of them having to clear it explicitly; a + // submit that admitted the message consumes it, a refused or failed one + // keeps it for the retry. + let stagedRewindQuotes: NonNullable = []; + let stagedQuotesSessionId: string | null = null; + const effectiveStagedQuotes = () => + stagedQuotesSessionId !== null && stagedQuotesSessionId === input.driver.getSessionId() + ? stagedRewindQuotes + : []; + const clearStagedQuotes = () => { + stagedRewindQuotes = []; + stagedQuotesSessionId = null; + }; let startAttachedTurn: ((attached: AttachedTurnContext) => void) | undefined; const startPendingAttachedTurn = () => { if (busy || turnRunning) return; @@ -675,6 +695,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providerRetry: state.providerRetry, uiLocale: locale, goal: input.driver.getGoal?.() ?? null, + stagedQuoteCount: effectiveStagedQuotes().length, ...(sideConversation ? { sideConversation: { @@ -1270,13 +1291,27 @@ 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(); + if (staged.length > 0) clearStagedQuotes(); 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); + if (staged.length > 0) { + stagedRewindQuotes = staged; + stagedQuotesSessionId = input.driver.getSessionId(); + } showSkillInvocation(result.skillInvocation); return; } @@ -1293,6 +1328,10 @@ 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); + if (staged.length > 0) { + stagedRewindQuotes = staged; + stagedQuotesSessionId = input.driver.getSessionId(); + } reportError(error); }) .finally(() => { @@ -2121,22 +2160,32 @@ 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) { + stagedRewindQuotes = result.quotes; + stagedQuotesSessionId = 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 @@ -4220,6 +4269,52 @@ 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') { + 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 773a6eb29a..606cb274c0 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -543,6 +543,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 } : {}), @@ -863,21 +864,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 }; @@ -898,6 +898,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 7b69b29bbd..fb6ad0e034 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, ShellRunSnapshotResult, ShellRunUpdate } from '@maka/core/events'; +import type { + SessionEvent, + QuoteRef, + ShellRunSnapshotResult, + 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'; @@ -65,6 +70,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 { @@ -111,6 +122,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 91ef6922d9..c5bdcee7da 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,12 @@ 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.', + 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 +1228,12 @@ export const TUI_COPY_RESOURCES = { '已回退到该轮之前(分支为新任务,原任务保留)。输入框已有未发送内容,未覆盖;该轮 prompt 已存入输入历史,可按 ↑ 找回。', noTargets: '没有可回退的轮次。', busy: '无法回退:当前有正在进行的操作 — 请等待其完成,或中断(Esc)后重试。', - unsupportedQuotes: - '无法回退到这一轮:它携带引用摘录,TUI 暂时无法把它们还原进替换 prompt。请改为回退到更早的纯文本轮次。', + quotesRestored: + '回退的这一轮带有引用内容:已恢复,并将随你的下一条消息一起提交——用 /quotes clear 丢弃。', + quotesCleared: '已丢弃恢复的引用;下一条消息不再携带。', + quotesNone: '当前没有暂存的恢复引用。', + quotesUsage: '用法:/quotes [clear]', + quotesListHeading: '暂存的引用:', unsupportedAttachments: '无法回退到这一轮:它携带附件,TUI 暂时无法把它们还原进替换 prompt。请改为回退到更早的纯文本轮次。', unsupportedDirectoryReferences: @@ -1238,8 +1249,12 @@ export const TUI_COPY_RESOURCES = { '已回退到該輪之前(分支為新任務,原任務保留)。輸入框已有未傳送內容,未覆蓋;該輪 prompt 已存入輸入歷史,可按 ↑ 找回。', noTargets: '沒有可回退的輪次。', busy: '無法回退:目前有正在進行的操作 — 請等待完成,或中斷(Esc)後重試。', - unsupportedQuotes: - '無法回退到這一輪:它攜帶引用摘錄,TUI 暫時無法把它們還原進替換 prompt。請改為回退到更早的純文字輪次。', + quotesRestored: + '回退的這一輪帶有引用內容:已恢復,並將隨你的下一則訊息一併送出——用 /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'] }, From eb5c88fc3fcc56849fddb954349ff9a7074caae6 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 13 Sep 2026 23:36:33 +0800 Subject: [PATCH 02/10] fix(cli): avoid unsafe optional chaining in the staged-quotes assertion assert.ok the recorded submit before reading its content, per the noUnsafeOptionalChaining lint rule. Part of #5109 Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/runtime-host-session-driver.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 b6d2ea4e8e..4609af1d5b 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2236,8 +2236,10 @@ describe('Runtime Host Maka Session driver', () => { 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( - (submit?.input as { content: { quotes?: unknown } }).content.quotes, + content.quotes, [{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-9' }], 'the driver forwards the staged QuoteRefs verbatim', ); From 6ca7ff99bf74d6e6caa024976bf40f2b1a7b0004 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Mon, 14 Sep 2026 21:10:46 +0800 Subject: [PATCH 03/10] fix(cli): scope quote restaging to the origin draft and admit quote-only rewinds Two review findings on #5265: A failed or blocked admission restaged the rewound quotes tagged with the Session read at callback time, so a Session switch while the admission was in flight attached the old quotes to the next message of the wrong conversation. The originating Session and staging generation are now captured at dispatch, and the restore happens only when neither moved. A quote-only rewind refills an empty prompt, and the empty-text guards in submitPrompt, steerRunningTurn and Alt+Enter rejected it before the quote forwarding path could run. They now treat staged quotes as meaningful content; a cleared plate stays truly empty and keeps refusing. Part of #5109 Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 119 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 34 +++-- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 5661220d4b..3fa376bd15 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6828,6 +6828,101 @@ Slug openai-work ]); }); + 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('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. + terminal.input('/quotes clear'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Restored quotes discarded'), + ); + 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( @@ -12105,6 +12200,30 @@ class QuotedRewindDriver extends RewindDriver { } } +/** + * 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')); + }); + } +} + /** * Holds `busy` from underneath an open picker: publishSuccessor-style, a * Host-started turn begins (and blocks on `turnGate`) while the rewind picker diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 34f7244e80..c55b664338 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -611,6 +611,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // keeps it for the retry. let stagedRewindQuotes: NonNullable = []; let stagedQuotesSessionId: string | null = null; + let stagedGeneration = 0; const effectiveStagedQuotes = () => stagedQuotesSessionId !== null && stagedQuotesSessionId === input.driver.getSessionId() ? stagedRewindQuotes @@ -618,6 +619,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const clearStagedQuotes = () => { stagedRewindQuotes = []; stagedQuotesSessionId = null; + stagedGeneration += 1; }; let startAttachedTurn: ((attached: AttachedTurnContext) => void) | undefined; const startPendingAttachedTurn = () => { @@ -1212,7 +1214,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; } @@ -1295,7 +1299,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // 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(); + const originGeneration = stagedGeneration; if (staged.length > 0) clearStagedQuotes(); + // A refusal or failure returns the quotes to the draft that dispatched + // them. The originating Session and staging generation are captured at + // dispatch: a Session switched, a newer rewind, or an explicit clear + // landing while the admission was in flight must not inherit context + // meant for the original conversation (#5109 review). + const restageForRetry = () => { + if (!staged.length) return; + if (input.driver.getSessionId() !== originSessionId) return; + if (stagedGeneration !== originGeneration) return; + stagedRewindQuotes = staged; + stagedQuotesSessionId = originSessionId; + }; const task = input.driver .submitMessage(text, { messageId, @@ -1308,10 +1326,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Retire the row it belongs to and report the failure in its place. if (result?.disposition === 'blocked') { removeTransientUserMessage(messageId); - if (staged.length > 0) { - stagedRewindQuotes = staged; - stagedQuotesSessionId = input.driver.getSessionId(); - } + restageForRetry(); showSkillInvocation(result.skillInvocation); return; } @@ -1328,10 +1343,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); - if (staged.length > 0) { - stagedRewindQuotes = staged; - stagedQuotesSessionId = input.driver.getSessionId(); - } + restageForRetry(); reportError(error); }) .finally(() => { @@ -1344,7 +1356,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; } @@ -1362,7 +1374,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); From 399f15e0849088896fa7a0c5d39bee2fa5f7e814 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 17 Sep 2026 07:09:46 +0800 Subject: [PATCH 04/10] fix(cli): make quote restaging reachable and generation-safe The restage guard captured stagedGeneration before the dispatch's own clearStagedQuotes(), which bumps the generation, so the guard compared against a pre-clear value and a blocked or failed admission never restored the staged quotes to the draft. Read the generation after the clear instead, and route every staged-quote write through one setter that bumps the generation, so a re-rewind landing while an admission is in flight is never overwritten by the older failure's restage (#5109 review). Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 144 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 33 ++-- 2 files changed, 164 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 3fa376bd15..c1896d7a26 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6875,6 +6875,115 @@ Slug openai-work ]); }); + 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('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( @@ -12224,6 +12333,41 @@ class HeldSubmitQuotedDriver extends QuotedRewindDriver { } } +/** + * 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; + } +} + /** * Holds `busy` from underneath an open picker: publishSuccessor-style, a * Host-started turn begins (and blocks on `turnGate`) while the rewind picker diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index c55b664338..08d3eecde4 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -616,11 +616,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { stagedQuotesSessionId !== null && stagedQuotesSessionId === input.driver.getSessionId() ? stagedRewindQuotes : []; - const clearStagedQuotes = () => { - stagedRewindQuotes = []; - stagedQuotesSessionId = null; + // 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); let startAttachedTurn: ((attached: AttachedTurnContext) => void) | undefined; const startPendingAttachedTurn = () => { if (busy || turnRunning) return; @@ -1300,19 +1308,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // restages them for the retry. const staged = effectiveStagedQuotes(); const originSessionId = input.driver.getSessionId(); - const originGeneration = stagedGeneration; if (staged.length > 0) clearStagedQuotes(); - // A refusal or failure returns the quotes to the draft that dispatched - // them. The originating Session and staging generation are captured at - // dispatch: a Session switched, a newer rewind, or an explicit clear - // landing while the admission was in flight must not inherit context - // meant for the original conversation (#5109 review). + // 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 = () => { if (!staged.length) return; if (input.driver.getSessionId() !== originSessionId) return; if (stagedGeneration !== originGeneration) return; - stagedRewindQuotes = staged; - stagedQuotesSessionId = originSessionId; + setStagedQuotes(staged, originSessionId); }; const task = input.driver .submitMessage(text, { @@ -2190,8 +2198,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // staging (#5109). clearStagedQuotes(); if (result.quotes?.length) { - stagedRewindQuotes = result.quotes; - stagedQuotesSessionId = input.driver.getSessionId(); + setStagedQuotes(result.quotes, input.driver.getSessionId()); state.entries.push({ kind: 'notice', level: 'info', From 8822a09114308d9045d15b6e3f230f855744572c Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 18 Sep 2026 06:58:27 +0800 Subject: [PATCH 05/10] fix(cli): surface staged-quote state across switches, clears, and the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-round review items on the rewind quote staging: - A session change now clears the staged quotes outright instead of only hiding them while the user is elsewhere: keying alone let a silent resurrection re-arm the quotes on return, potentially many turns later. The rewind re-stages its own quotes after the switch settles. - /quotes clear distinguishes the nothing-staged case (including quotes that already left on an in-flight submit) instead of always claiming a discard. - A quote-only submit stored no text, so the replacement message left no trace in the transcript — an answer to an invisible prompt. The durable user entry now carries the restored-quote count and renders a trace line for it. - Coverage: the blocked-disposition restage, two-quote ordering across the status line, /quotes listing, and the submit, and /quotes routing mid-turn. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-transcript.test.ts | 39 +++ .../cli/src/__tests__/pi-tui-runner.test.ts | 223 +++++++++++++++++- packages/cli/src/pi-transcript.ts | 22 +- packages/cli/src/pi-tui-runner.ts | 26 +- 4 files changed, 301 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 2a2e1bea3a..eaacc65722 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -86,6 +86,45 @@ describe('Maka Pi TUI transcript', () => { } }); + test('traces restored 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 restored quotes/); + assert.match(rendered, /· 1 restored quote/); + assert.match(rendered, /with words/); + assert.equal( + (rendered.match(/restored quote/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 c1896d7a26..f030d7f028 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7014,10 +7014,12 @@ Slug openai-work ]); // 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('Restored quotes discarded'), + plainTerminalOutput(terminal.output()).includes('No restored quotes are staged'), ); terminal.input('\r'); await delay(200); @@ -7092,6 +7094,148 @@ Slug openai-work ]); }); + 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( @@ -12368,12 +12512,87 @@ class PerRewindQuotedDriver extends HeldSubmitQuotedDriver { } } +/** 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/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index f049694176..4e4ae9db08 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; + /** Restored-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 } @@ -1089,10 +1096,12 @@ function storedMessagesToTranscriptEntries( } else if (message.origin?.kind === 'goal') { entries.push({ kind: 'goal_continuation', text: message.displayText ?? message.text }); } else { + const restoredQuotes = message.quotes?.length; entries.push({ kind: 'user', messageId: message.id, text: message.displayText ?? message.text, + ...(restoredQuotes ? { quotes: restoredQuotes } : {}), }); } break; @@ -1581,8 +1590,15 @@ 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). + if (entry.quotes === undefined) return lines; + const hint = `· ${entry.quotes} restored quote${entry.quotes === 1 ? '' : 's'}`; + return [...lines, ...renderUserBlock(hint, contentWidth)]; + } case 'legacy_automation': return renderLegacyAutomationBlock(entry.text, contentWidth); case 'goal_continuation': diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 08d3eecde4..8f95264bf8 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -605,10 +605,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 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 every switch path - // invalidates it without each of them having to clear it explicitly; a - // submit that admitted the message consumes it, a refused or failed one - // keeps it for the retry. + // 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; @@ -1842,6 +1843,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(); @@ -4295,6 +4302,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 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. + if (effectiveStagedQuotes().length === 0) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: TUI_REWIND_COPY[locale].quotesNone, + }); + requestRender(); + return; + } clearStagedQuotes(); state.entries.push({ kind: 'notice', From a127b13ed2b7e2c48db30f84b1dc96dd05662117 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 18 Sep 2026 20:04:31 +0800 Subject: [PATCH 06/10] chore: retrigger CI after a flaky PTY resource-process test The runtime-host PTY close-wait timeout fired on a merge head whose runtime-host tree is identical to green upstream; the PR's delta is confined to packages/cli. Local pi-tui + transcript suites pass on the merge head. From cb6d10882c0c460ada24590c7f34c4cb6b2cd62e Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 20 Sep 2026 23:08:24 +0800 Subject: [PATCH 07/10] fix(cli): restage quotes on an unknown submit outcome and neutralize the quote trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the adversarial re-review at a127b13ed: - An outcome_unknown submit (or an interruption after the dispatch went out) resolves without a receipt, and the dispatch had already consumed the quote staging, so the quotes vanished silently with no restage and no test coverage. Restage them when the receipt is absent and surface a notice naming the uncertainty: admission cannot be proven either way, and losing the user's explicit context to an unproven outcome is worse than a visible duplicate ride (status line shows the restore; /quotes clear discards it). - The durable user-entry trace said "restored quote(s)" for every message carrying quotes, but StoredMessage.quotes also carries plain desktop quotes (including edit-restaged ones), so any quoted message resurfaced in the TUI mislabeled itself as rewind-restored. Word the trace neutrally ("· N quote(s)"). New UnknownOutcomeSubmitDriver pins the resolve-undefined path the previous tests only exercised through rejection. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-transcript.test.ts | 8 +-- .../cli/src/__tests__/pi-tui-runner.test.ts | 70 +++++++++++++++++++ packages/cli/src/pi-transcript.ts | 12 ++-- packages/cli/src/pi-tui-runner.ts | 26 +++++-- 4 files changed, 103 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 462c324bc7..763b4662f3 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -104,7 +104,7 @@ describe('Maka Pi TUI transcript', () => { } }); - test('traces restored quotes on the durable user entry', () => { + test('traces quotes on the durable user entry', () => { const state = createMakaPiTranscriptState(); const excerpt = { text: 'a large pasted excerpt', @@ -133,11 +133,11 @@ describe('Maka Pi TUI transcript', () => { { 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 restored quotes/); - assert.match(rendered, /· 1 restored quote/); + assert.match(rendered, /· 2 quotes/); + assert.match(rendered, /· 1 quote/); assert.match(rendered, /with words/); assert.equal( - (rendered.match(/restored quote/g) ?? []).length, + (rendered.match(/· \d+ quotes?/g) ?? []).length, 2, 'messages without quotes render no hint', ); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2debc5b8cd..16f77ab45a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7700,6 +7700,57 @@ Slug openai-work ]); }); + 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; staged quotes restored for retry.', + ), + ); + + 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( @@ -13263,6 +13314,25 @@ class HeldSubmitQuotedDriver extends QuotedRewindDriver { } } +/** + * 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. diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index e9932b58dd..7c071cb5e1 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -177,7 +177,7 @@ export type MakaPiTranscriptEntry = messageId: string; text: string; transient?: boolean; - /** Restored-quote count the message rode in on; rendered as a trace line. */ + /** Quote count the message rode in on; rendered as a trace line. */ quotes?: number; } | { kind: 'legacy_automation'; text: string } @@ -1096,12 +1096,12 @@ function storedMessagesToTranscriptEntries( } else if (message.origin?.kind === 'goal') { entries.push({ kind: 'goal_continuation', text: message.displayText ?? message.text }); } else { - const restoredQuotes = message.quotes?.length; + const quoteCount = message.quotes?.length; entries.push({ kind: 'user', messageId: message.id, text: message.displayText ?? message.text, - ...(restoredQuotes ? { quotes: restoredQuotes } : {}), + ...(quoteCount ? { quotes: quoteCount } : {}), }); } break; @@ -1593,9 +1593,11 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) 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). + // 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} restored quote${entry.quotes === 1 ? '' : 's'}`; + const hint = `· ${entry.quotes} quote${entry.quotes === 1 ? '' : 's'}`; return [...lines, ...renderUserBlock(hint, contentWidth)]; } case 'legacy_automation': diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index a25760a793..052fe32a36 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1408,11 +1408,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // flight has since bumped it and must not inherit context meant for the // original conversation (#5109 review). const originGeneration = stagedGeneration; - const restageForRetry = () => { - if (!staged.length) return; - if (input.driver.getSessionId() !== originSessionId) return; - if (stagedGeneration !== originGeneration) return; + 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, { @@ -1430,6 +1431,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 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: 'Submit outcome unknown; staged quotes restored for retry.', + }); + } + 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 From c07c577a79bc4683341791a53ee0827c335f8ede Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 20 Sep 2026 23:27:48 +0800 Subject: [PATCH 08/10] fix(cli): route the unknown-outcome notice through the rewind copy catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice introduced for the unknown submit outcome was a visible literal in pi-tui-runner, which check:tui-copy correctly rejects — TUI copy must go through the localized catalog. Add quotesRestoredUnknown to the rewind catalog (en / zh-CN / zh-TW) and reference it. Generated-by: GLM-5.3-Flash (ZCode) --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 4 +--- packages/cli/src/pi-tui-runner.ts | 3 ++- packages/cli/src/tui-copy-catalog.ts | 6 ++++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 16f77ab45a..e993245b84 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7730,9 +7730,7 @@ Slug openai-work // a notice naming the uncertainty (#5109 review). driver.resolveUnknown(); await waitFor(() => - plainTerminalOutput(terminal.output()).includes( - 'Submit outcome unknown; staged quotes restored for retry.', - ), + plainTerminalOutput(terminal.output()).includes('Submit outcome unknown'), ); terminal.input('retry then'); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 052fe32a36..fb2d890c54 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -410,6 +410,7 @@ interface TuiRewindCopy { readonly noTargets: string; readonly busy: string; readonly quotesRestored: string; + readonly quotesRestoredUnknown: string; readonly quotesCleared: string; readonly quotesNone: string; readonly quotesUsage: string; @@ -1443,7 +1444,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: 'Submit outcome unknown; staged quotes restored for retry.', + text: TUI_REWIND_COPY[locale].quotesRestoredUnknown, }); } return; diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 8d9d429638..e460ddfabb 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -1208,6 +1208,8 @@ export const TUI_COPY_RESOURCES = { busy: 'Cannot rewind: another action is in progress — wait for it to finish, or interrupt (Esc) and retry.', 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]', @@ -1230,6 +1232,8 @@ export const TUI_COPY_RESOURCES = { busy: '无法回退:当前有正在进行的操作 — 请等待其完成,或中断(Esc)后重试。', quotesRestored: '回退的这一轮带有引用内容:已恢复,并将随你的下一条消息一起提交——用 /quotes clear 丢弃。', + quotesRestoredUnknown: + '发送结果未知:暂存的引用已恢复,将随你的下一条消息一起提交——用 /quotes clear 丢弃。', quotesCleared: '已丢弃恢复的引用;下一条消息不再携带。', quotesNone: '当前没有暂存的恢复引用。', quotesUsage: '用法:/quotes [clear]', @@ -1251,6 +1255,8 @@ export const TUI_COPY_RESOURCES = { busy: '無法回退:目前有正在進行的操作 — 請等待完成,或中斷(Esc)後重試。', quotesRestored: '回退的這一輪帶有引用內容:已恢復,並將隨你的下一則訊息一併送出——用 /quotes clear 捨棄。', + quotesRestoredUnknown: + '傳送結果未知:暫存的引用已恢復,將隨你的下一則訊息一併送出——用 /quotes clear 捨棄。', quotesCleared: '已捨棄恢復的引用;下一則訊息不再攜帶。', quotesNone: '目前沒有暫存的恢復引用。', quotesUsage: '用法:/quotes [clear]', From dcb9c22a49abef31cc32c02d26ef67fc4b8029d9 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 20 Sep 2026 23:36:20 +0800 Subject: [PATCH 09/10] style(cli): collapse the shortened waitFor assertion to one line Generated-by: GLM-5.3-Flash (ZCode) --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index e993245b84..cbce50fcf7 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7729,9 +7729,7 @@ Slug openai-work // 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'), - ); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Submit outcome unknown')); terminal.input('retry then'); terminal.input('\r'); From b261e8dd23f90ade65339c4f736d25944c4ae7c4 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 26 Sep 2026 21:14:39 +0800 Subject: [PATCH 10/10] fix(cli): supersede a pending quote restoration on submits and clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ordinary submit that carries no quotes and an explicit /quotes clear with nothing staged leave the staging untouched, so neither advanced the generation — an in-flight submit's failure callback could then re-arm its QuoteRefs onto a conversation the user had already moved past. Advance the generation on both, making the guard airtight by construction rather than by the reader's memory of which writes bump it. Carries the #5109 review finding on the runner. Generated-by: GLM-5.3-Flash (ZCode) --- packages/cli/src/pi-tui-runner.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index c8a5cc77ec..775db5d309 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -720,6 +720,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 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; @@ -1402,6 +1411,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 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 @@ -4697,7 +4707,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 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',