From 93a8fdf0ce60e90b0e1692509991c3290d1ec583 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 13 Sep 2026 23:27:05 +0800 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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', From fea2f7ac22939c6d908f14999fc070d276df9946 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 27 Sep 2026 20:20:09 +0800 Subject: [PATCH 11/22] fix(cli): restage a retracted message's quotes instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-round review (#5109): a queued or steered submit carries the staged quotes, but taking the message back (Alt+↑ or Esc) lost them. The Host's `queue.retract` returns the full `MessageContent` — quotes included — while `retractQueued()` kept only the text and message ids, and `acceptRetraction` refilled only the text. A resubmit then went out without the quotes, and a quote-only message left an empty editor. `MakaRetractedMessages` now carries the retracted entries' quotes and `acceptRetraction` restages them on the current session through the generation-guarded setter, so the restoration obeys the same veto rules as every other staging write. Two runner tests cover one and several retracted messages. Also drop the unused `ShellRunSnapshotResult` import. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 165 +++++++++++++++++- packages/cli/src/pi-tui-runner.ts | 21 ++- .../cli/src/runtime-host-session-driver.ts | 5 +- packages/cli/src/session-driver.ts | 8 +- 4 files changed, 192 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 61a266ae83..a39ea201cc 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -60,9 +60,11 @@ import type { MakaSessionSwitchResult, MakaSubmitMessageOptions, MakaTranscriptReplacementReason, + MakaRetractedMessages, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; +import type { QuoteRef } from '@maka/core/events'; import { skillInvocationBlockedMessage } from '../session-driver.js'; import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js'; import { listApiKeyOnboardableProviders, onboardingCreateTarget } from '../onboarding-catalog.js'; @@ -8108,6 +8110,117 @@ Slug openai-work ]); }); + test('restages a steered quote submit when it is retracted mid-turn', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // The running Turn turns the submit into a steering message carrying the + // staged quotes; the staging clears as it dispatches. + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + // Alt+Up takes the message back. The Host's queue.retract returns the + // full MessageContent, so the quotes ride the retraction — and they must + // land back in the staging instead of vanishing with the queue row + // (#5109 review, third round). + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // The retry carries them again. + terminal.input('retry then'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('restages the quoted message among several retracted queue entries', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + // One quoted steering message and one plain queued follow-up. + terminal.input('quoted resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + await waitFor(() => driver.queuedRows.length === 1); + terminal.input('plain follow-up'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.equal(driver.submittedQuotes[1], undefined); + await waitFor(() => driver.queuedRows.length === 2); + + terminal.input('\x1b[1;3A'); // Alt+Up retracts both. + await waitFor(() => driver.retractCalls === 1); + // The quotes survive a multi-message retraction; the texts are both back. + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return screen.includes('quoted resend') && screen.includes('plain follow-up'); + }); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -11781,7 +11894,7 @@ class SteeringTurnDriver extends FakeSessionDriver { }; } - async retractQueued(): Promise<{ text: string; messageIds: readonly string[] }> { + async retractQueued(): Promise { this.retractCalls += 1; const retracted = [...this.steering, ...this.followup]; const joined = retracted.map((entry) => entry.text).join('\n\n'); @@ -11790,7 +11903,7 @@ class SteeringTurnDriver extends FakeSessionDriver { this.emitQueueUpdate(); this.wakeTurn?.(); this.wakeTurn = null; - return { text: joined, messageIds: retracted.map((entry) => entry.messageId) }; + return { text: joined, messageIds: retracted.map((entry) => entry.messageId), quotes: [] }; } // Simulates the runtime consuming the steering queue at a step boundary @@ -13543,6 +13656,54 @@ class MidTurnQuotesDriver extends QuotedRewindDriver { } } +/** + * A queued or steered submit's quotes ride the queue entry: `retractQueued` + * returns them the way the Host's `queue.retract` returns the full + * `MessageContent`, so a retraction can hand them back to the staging. + */ +class RetractingQuotesDriver extends MidTurnQuotesDriver { + readonly queuedRows: Array<{ messageId: string; text: string; quotes: readonly QuoteRef[] }> = []; + readonly retractedQuoteLoads: Array = []; + #retractCalls = 0; + #turnStarted = false; + + get retractCalls(): number { + return this.#retractCalls; + } + + override startBlockingTurn(): void { + this.#turnStarted = true; + super.startBlockingTurn(); + } + + override submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + if (this.#turnStarted) { + this.queuedRows.push({ + messageId: options.messageId, + text, + quotes: options.quotes ?? [], + }); + } + return super.submitMessage(text, options); + } + + async retractQueued(): Promise { + this.#retractCalls += 1; + const quotes = this.queuedRows.flatMap((row) => row.quotes); + const retracted = { + text: this.queuedRows.map((row) => row.text).join('\n\n'), + messageIds: this.queuedRows.map((row) => row.messageId), + quotes, + }; + this.queuedRows.length = 0; + this.retractedQuoteLoads.push(quotes); + return retracted; + } +} + /** * 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 775db5d309..92c226e087 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -93,6 +93,7 @@ import { inspectSessionResumeAvailability, type MakaAttachedSessionTurn, type MakaPreparedSessionTurn, + type MakaRetractedMessages, type MakaSessionDriver, type MakaSessionRewindResult, type MakaSideConversationParentStatus, @@ -1309,7 +1310,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // connection where both calls are asynchronous. void (async () => { await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + const retracted = (await input.driver.retractQueued?.()) ?? { + text: '', + messageIds: [], + quotes: [], + }; acceptRetraction(retracted); requestRender(); await input.driver.stop(); @@ -1386,9 +1391,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (index >= 0) state.entries.splice(index, 1); }; - const acceptRetraction = (retracted: { text: string; messageIds: readonly string[] }) => { + const acceptRetraction = (retracted: MakaRetractedMessages) => { for (const messageId of retracted.messageIds) removeTransientUserMessage(messageId); refillEditorFromQueues(retracted.text); + // The Host returns the full MessageContent with a retraction: quotes that + // rode a queued or steered message come back with it and restage here, so + // the re-edited retry does not go out without them (#5109 review). + if (retracted.quotes.length > 0) { + setStagedQuotes(retracted.quotes, input.driver.getSessionId()); + } }; /** @@ -1517,7 +1528,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const retractQueuedMessages = () => { void (async () => { await settlePendingEnqueues(); - const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [] }; + const retracted = (await input.driver.retractQueued?.()) ?? { + text: '', + messageIds: [], + quotes: [], + }; acceptRetraction(retracted); requestRender(); })().catch(reportError); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index ab1a7918fc..ce59890f64 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -585,7 +585,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } async retractQueued(): Promise { - if (!this.#sessionId) return { text: '', messageIds: [] }; + if (!this.#sessionId) return { text: '', messageIds: [], quotes: [] }; const result = await this.#request('queue.retract', { originHostEpoch: this.#connection.hostEpoch, sessionId: this.#sessionId, @@ -594,6 +594,9 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return { text: result.retracted.map((entry) => entry.content.text).join('\n\n'), messageIds: result.retracted.map((entry) => entry.messageId), + // The Host returns the full MessageContent for every retracted entry: + // the quotes ride back so the runner can restage them (#5109 review). + quotes: result.retracted.flatMap((entry) => entry.content.quotes ?? []), }; } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 94007c90c5..f9a8d2c5de 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -21,7 +21,6 @@ import { realpath } from 'node:fs/promises'; import type { SessionEvent, QuoteRef, - ShellRunSnapshotResult, ShellRunStateResult, ShellRunUpdate, } from '@maka/core/events'; @@ -134,6 +133,13 @@ export interface MakaSubmitMessageOptions { export interface MakaRetractedMessages { text: string; messageIds: readonly string[]; + /** + * The queued messages' inline excerpts, returned verbatim by the Host's + * `queue.retract` — the full `MessageContent` comes back with the + * retraction, so a queued submit's quotes can restage instead of + * vanishing with the queue row (#5109 review). + */ + quotes: readonly QuoteRef[]; } /** From 92615d63fab358801b8612883a16e25ab2af0b0c Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 27 Sep 2026 20:45:51 +0800 Subject: [PATCH 12/22] test(cli): expect the retraction quotes in the driver contract test The previous commit added the quotes field to MakaRetractedMessages but missed this deepStrictEqual expectation. Generated-by: GLM-5.3-Flash (ZCode) --- packages/cli/src/__tests__/runtime-host-session-driver.test.ts | 1 + 1 file changed, 1 insertion(+) 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 894947293f..dd95af1042 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1789,6 +1789,7 @@ describe('Runtime Host Maka Session driver', () => { assert.deepEqual(await driver.retractQueued!(), { text: 'Later', messageIds: ['message-1'], + quotes: [], }); assert.deepEqual( connection.requests.filter( From 668a57654af673a04b2948721fae36e881f178aa Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 27 Sep 2026 22:28:43 +0800 Subject: [PATCH 13/22] fix(cli): fence a retraction to the session it was asked for A mid-turn `/session` can land while Alt+Up's `queue.retract` is still in flight (switchAwayMidTurn does not wait for it). The retraction response then restored its text and restaged its quotes under the session the driver had moved to, where the next submit could carry the abandoned context into the wrong conversation (#5109 review). Capture the owning session before the first await and discard the response when the driver has re-keyed; the switched-to session keeps its own empty staging. Covered by a delayed-retract/session-switch regression. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 63 +++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 16 ++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index a39ea201cc..b68a990edb 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8221,6 +8221,60 @@ Slug openai-work ]); }); + test('discards a retraction that lands after a mid-turn session switch', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Hold the retraction in flight, switch mid-turn, then release it. The + // retraction describes the session we left: its quotes must not stage + // into the session we landed on, where the next submit could carry the + // abandoned context (#5109 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + driver.switchSession('session-other'); + driver.retractGate.resolve(); + await waitFor(() => driver.retractedQuoteLoads.length === 1); + await delay(30); + assert.doesNotMatch( + plainTerminalOutput(terminal.screenOutput()), + /quotes:1/, + 'the abandoned retraction must not stage into the switched-to session', + ); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -13664,6 +13718,7 @@ class MidTurnQuotesDriver extends QuotedRewindDriver { class RetractingQuotesDriver extends MidTurnQuotesDriver { readonly queuedRows: Array<{ messageId: string; text: string; quotes: readonly QuoteRef[] }> = []; readonly retractedQuoteLoads: Array = []; + retractGate: ReturnType> | undefined = undefined; #retractCalls = 0; #turnStarted = false; @@ -13692,6 +13747,7 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { async retractQueued(): Promise { this.#retractCalls += 1; + if (this.retractGate) await this.retractGate.promise; const quotes = this.queuedRows.flatMap((row) => row.quotes); const retracted = { text: this.queuedRows.map((row) => row.text).join('\n\n'), @@ -13702,6 +13758,13 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { this.retractedQuoteLoads.push(quotes); return retracted; } + + override async switchSession(sessionId: string): Promise { + // The base fake leaves `sessionId` alone; a mid-turn switch must move the + // driver's session for the retraction-fence scenario to be reachable. + this.sessionId = sessionId; + return super.switchSession(sessionId); + } } /** diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 92c226e087..4a8c5e69b2 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1309,14 +1309,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Serializing these operations also preserves that ordering over a Host // connection where both calls are asynchronous. void (async () => { + // Fence the retraction to the session it was asked for: a mid-turn + // `/session` landing while the Host call is in flight re-keys the + // driver, and neither the retracted text nor its quotes may land in + // the session we switched to (#5109 review). + const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [], quotes: [], }; - acceptRetraction(retracted); - requestRender(); + if (input.driver.getSessionId() === retractionSessionId) { + acceptRetraction(retracted); + requestRender(); + } await input.driver.stop(); })().catch((error) => { interruptRequested = false; @@ -1527,12 +1534,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // prepended to the current draft for re-editing. const retractQueuedMessages = () => { void (async () => { + // Same session fence as the interrupt path: a mid-turn `/session` that + // lands while the retraction is in flight must not inherit the quotes + // or the text of the session we left (#5109 review). + const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [], quotes: [], }; + if (input.driver.getSessionId() !== retractionSessionId) return; acceptRetraction(retracted); requestRender(); })().catch(reportError); From d9aa90ee5d0e2408ab087256590b7a79b20ca91e Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Mon, 28 Sep 2026 06:21:39 +0800 Subject: [PATCH 14/22] fix(cli): fence the whole retraction, and its stop, to the owning session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the retraction fence (#5109 review): - The session check ran after `retractQueued()`. A parked enqueue inside `settlePendingEnqueues()` let a mid-turn `/session` complete during the wait, so the Host call read the driver's *new* session and retracted its queued messages — which the mismatch fence then discarded silently. The fence now runs before the call: a moved-past retraction never touches the new session's queue. - The interrupt path still called `driver.stop()` unconditionally, so a Ctrl+C aimed at the abandoned session aborted the switched-to session's running turn. The stop now sits inside the fence; the switch flow owns stopping the session it left. Both regressions drive the retraction through a held gate while the session switch completes: no `retractQueued` call, and no stop on the landed-on session. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 116 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 23 ++-- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index b68a990edb..1a33a196ef 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8221,6 +8221,108 @@ Slug openai-work ]); }); + test('does not retract the switched-to queue when enqueues settle after a switch', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Park a second enqueue inside the driver, then Alt+Up: the runner must + // wait for that enqueue, notice the session moved underneath it, and drop + // the retraction *before* calling retractQueued — otherwise the Host call + // retracts the switched-to session's queue and the mismatch fence then + // discards those messages silently (#5109 review). + driver.enqueueGate = deferred(); + terminal.input('second queued'); + terminal.input('\r'); + driver.switchSession('session-other'); + driver.enqueueGate.resolve(); + await waitFor(() => driver.submittedQuotes.length === 2); + await delay(30); + assert.equal(driver.retractCalls, 0, 'the moved-past retraction must not run'); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('keeps the interrupt stop bound to the session it was asked for', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Ctrl+C interrupts the running turn; the pre-stop retraction is held on + // a gate while the user switches sessions. The switch owns stopping the + // old turn — the fenced interrupt must not stop the session it landed on + // (#5109 review). + driver.retractGate = deferred(); + terminal.input('\x1b'); // double Escape arms and fires the interrupt + terminal.input('\x1b'); + await waitFor(() => driver.retractCalls === 1); + driver.switchSession('session-other'); + driver.retractGate.resolve(); + await waitFor(() => driver.retractedQuoteLoads.length === 1); + await delay(30); + assert.equal(driver.stopCalls, 0, 'the fenced stop must not hit the switched-to session'); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('discards a retraction that lands after a mid-turn session switch', async () => { const terminal = new FakeTerminal(); const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); @@ -13719,13 +13821,19 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { readonly queuedRows: Array<{ messageId: string; text: string; quotes: readonly QuoteRef[] }> = []; readonly retractedQuoteLoads: Array = []; retractGate: ReturnType> | undefined = undefined; + enqueueGate: ReturnType> | undefined = undefined; #retractCalls = 0; + #stopCalls = 0; #turnStarted = false; get retractCalls(): number { return this.#retractCalls; } + get stopCalls(): number { + return this.#stopCalls; + } + override startBlockingTurn(): void { this.#turnStarted = true; super.startBlockingTurn(); @@ -13741,10 +13849,18 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { text, quotes: options.quotes ?? [], }); + if (this.enqueueGate) { + const gate = this.enqueueGate; + return gate.promise.then(() => super.submitMessage(text, options)); + } } return super.submitMessage(text, options); } + override async stop(): Promise { + this.#stopCalls += 1; + } + async retractQueued(): Promise { this.#retractCalls += 1; if (this.retractGate) await this.retractGate.promise; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 4a8c5e69b2..6ab7c13864 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1309,21 +1309,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Serializing these operations also preserves that ordering over a Host // connection where both calls are asynchronous. void (async () => { - // Fence the retraction to the session it was asked for: a mid-turn - // `/session` landing while the Host call is in flight re-keys the - // driver, and neither the retracted text nor its quotes may land in - // the session we switched to (#5109 review). + // Fence the retraction and the stop to the session they were asked for: + // a mid-turn `/session` landing while enqueues or the Host call are in + // flight re-keys the driver, and the abandoned retraction must neither + // retract the new session's queue nor stop its running turn, nor land + // its text or quotes there (#5109 review). const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); + if (input.driver.getSessionId() !== retractionSessionId) return; const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [], quotes: [], }; - if (input.driver.getSessionId() === retractionSessionId) { - acceptRetraction(retracted); - requestRender(); + if (input.driver.getSessionId() !== retractionSessionId) { + return; } + acceptRetraction(retracted); + requestRender(); await input.driver.stop(); })().catch((error) => { interruptRequested = false; @@ -1535,10 +1538,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const retractQueuedMessages = () => { void (async () => { // Same session fence as the interrupt path: a mid-turn `/session` that - // lands while the retraction is in flight must not inherit the quotes - // or the text of the session we left (#5109 review). + // lands while enqueues or the retraction are in flight must neither + // retract the new session's queue nor inherit the quotes or the text + // of the session we left (#5109 review). const retractionSessionId = input.driver.getSessionId(); await settlePendingEnqueues(); + if (input.driver.getSessionId() !== retractionSessionId) return; const retracted = (await input.driver.retractQueued?.()) ?? { text: '', messageIds: [], From f0b31c98ec633d7d7640c3853789b2b6478dc4f2 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Mon, 28 Sep 2026 08:09:37 +0800 Subject: [PATCH 15/22] fix(cli): serialize session switches behind an in-flight retraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth-round review (#5109 review): the retraction fence discarded the retracted payload whenever the driver re-keyed mid-flight, and every switch entry point had its own gap — the idle `/session` path, the activity-lease wait inside `runControl`, the detach toggle, and Alt+Up's own `settlePendingEnqueues` wait all allowed a switch to land between the Host retraction and its response. Serialize instead: both retraction paths register on a retraction task set, and `switchSession`/`switchAwayMidTurn` await `settleRetractions()` at their entry. The retracted text and quotes therefore always land in the session they were asked for (via the existing acceptRetraction restore) before any switch re-keys the driver; the parked-enqueue regression now fires Alt+Up while the enqueue is held, so it exercises the real race, and a new regression pins the switch-behind-retraction ordering. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 70 +++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 27 ++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 1a33a196ef..21375a9f53 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8254,6 +8254,7 @@ Slug openai-work driver.enqueueGate = deferred(); terminal.input('second queued'); terminal.input('\r'); + terminal.input('\x1b[1;3A'); // Alt+Up: waits on the parked enqueue driver.switchSession('session-other'); driver.enqueueGate.resolve(); await waitFor(() => driver.submittedQuotes.length === 2); @@ -8323,6 +8324,72 @@ Slug openai-work ]); }); + test('serializes a mid-turn session switch behind an in-flight retraction', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // The Alt+Up retraction is held on the Host call; a mid-turn `/session` + // arriving while it is in flight must wait for it — the retracted text + // and quotes land in the session they were asked for before the driver + // re-keys (#5109 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-other'); + terminal.input('\r'); + driver.retractGate.resolve(); + await delay(200); + if (process.env.PROFILE_DEBUG) { + console.log('DEBUG eventLog:', driver.eventLog.join(' | ')); + console.log('DEBUG queuedRows:', driver.queuedRows.length); + console.log('DEBUG stopCalls:', driver.stopCalls); + } + await waitFor(() => driver.retractedQuoteLoads.length === 1); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const switchDone = driver.eventLog.findIndex((entry) => entry.startsWith('switch:')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + switchDone === -1 || retractDone < switchDone, + 'the switch waits behind the retraction: ' + driver.eventLog.join(','), + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('discards a retraction that lands after a mid-turn session switch', async () => { const terminal = new FakeTerminal(); const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); @@ -13820,6 +13887,7 @@ class MidTurnQuotesDriver extends QuotedRewindDriver { class RetractingQuotesDriver extends MidTurnQuotesDriver { readonly queuedRows: Array<{ messageId: string; text: string; quotes: readonly QuoteRef[] }> = []; readonly retractedQuoteLoads: Array = []; + readonly eventLog: string[] = []; retractGate: ReturnType> | undefined = undefined; enqueueGate: ReturnType> | undefined = undefined; #retractCalls = 0; @@ -13872,12 +13940,14 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { }; this.queuedRows.length = 0; this.retractedQuoteLoads.push(quotes); + this.eventLog.push(`retract-done:${this.sessionId}`); return retracted; } override async switchSession(sessionId: string): Promise { // The base fake leaves `sessionId` alone; a mid-turn switch must move the // driver's session for the retraction-fence scenario to be reachable. + this.eventLog.push(`switch:${sessionId}`); this.sessionId = sessionId; return super.switchSession(sessionId); } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 6ab7c13864..71c3e398f9 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1291,6 +1291,20 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { await Promise.allSettled([...pendingEnqueueTasks]); } }; + // An in-flight retraction serializes navigation: a session switch waits + // for it, so the retracted text and quotes land in the session they were + // asked for instead of being discarded after the driver re-keyed (#5109 + // review). + const pendingRetractionTasks = new Set>(); + const trackRetraction = (task: Promise): void => { + pendingRetractionTasks.add(task); + void task.finally(() => pendingRetractionTasks.delete(task)); + }; + const settleRetractions = async (): Promise => { + while (pendingRetractionTasks.size > 0) { + await Promise.allSettled([...pendingRetractionTasks]); + } + }; const requestTurnInterrupt = () => { // A detach in flight is not the running Turn's owner acting on it — the @@ -1308,7 +1322,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // for re-editing, while anything already consumed stays in the transcript. // Serializing these operations also preserves that ordering over a Host // connection where both calls are asynchronous. - void (async () => { + const retractionTask = (async () => { // Fence the retraction and the stop to the session they were asked for: // a mid-turn `/session` landing while enqueues or the Host call are in // flight re-keys the driver, and the abandoned retraction must neither @@ -1333,6 +1347,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.disableSubmit = false; reportError(error); }); + trackRetraction(retractionTask); }; // Open a fresh turn from a submitted prompt (idle path). Control actions hold @@ -1536,7 +1551,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Alt+↑: take back every queued message from the Runtime Host, joined and // prepended to the current draft for re-editing. const retractQueuedMessages = () => { - void (async () => { + const retractionTask = (async () => { // Same session fence as the interrupt path: a mid-turn `/session` that // lands while enqueues or the retraction are in flight must neither // retract the new session's queue nor inherit the quotes or the text @@ -1553,6 +1568,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { acceptRetraction(retracted); requestRender(); })().catch(reportError); + trackRetraction(retractionTask); }; // Onboarding wizard (#1098 UX redesign): one overlay spans provider search, @@ -2019,6 +2035,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // A failure leaves the active session untouched and the next prompt still // lands on the old one. const switchSession = async (sessionId: string, relocateCwd?: string) => { + // A session switch waits for an in-flight retraction: the retracted text + // and quotes must land in the session they were asked for before the + // driver re-keys (#5109 review). + await settleRetractions(); resolvedInteractionIds.clear(); const result = await input.driver.switchSession( sessionId, @@ -2072,6 +2092,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // then either that tail or the startPendingAttachedTurn below starts the // freshly attached Turn, whichever observes an idle runner first. const switchAwayMidTurn = async (sessionId: string) => { + // Same serialization as the idle switch: the in-flight retraction lands + // its payload in the session it was asked for first (#5109 review). + await settleRetractions(); resolvedInteractionIds.clear(); detaching = true; try { From a16a934d7e8479cbac82f91d410161a1f28da647 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 29 Sep 2026 00:04:50 +0800 Subject: [PATCH 16/22] fix(cli): pin the idle switch race behind a held activity lease Sixth-round review (#5265 review) flagged the idle `/session` path: the retraction guard ran before `runControl`, but `runControl` parks on `activities.acquire` before reaching the switch, and Alt+Up stays live during that wait - a retraction started there could land after the released lease let the switch re-key, and the switched-session fence would discard the text and quotes the Host had already taken back. The serialization at the switch boundary (`switchSession` awaits `settleRetractions()` before re-keying) already closes that race on this head and supersedes the reviewed branch's flag-based guard. Pin the reversed order with a regression: hold the session's activity lease, start the idle `/session` so `runControl` parks on the lease, fire Alt+Up while it waits, then release - the retracted text and quotes must reach the editor before the switch re-keys. With the boundary await removed the test fails (the switch re-keys before the retraction lands, `switch:...` then `retract-done:...`); with it, the four focused retraction tests pass individually. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 4aeca2eace..5e4d340b9d 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8390,6 +8390,89 @@ Slug openai-work ]); }); + test('serializes an idle /session switch behind a retraction started during its activity wait', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const activities = new SessionActivityRegistry(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + turnActivity: createTestTurnActivity(activities), + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Deliberately hold the session's activity lease, then start the idle + // `/session`: runControl parks on activities.acquire before it can reach + // the switch (#5265 review). + const heldLease = await activities.acquire(driver.getSessionId()!); + terminal.input('/session session-other'); + terminal.input('\r'); + await waitFor(() => terminal.progressStates.at(-1) === true); + + // Alt+Up during that wait: the root key handler still retracts, and the + // retraction must land before the parked switch re-keys the driver — the + // switched-session fence would otherwise discard what the Host already + // removed from the queue (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + + heldLease.release(); + // Give the parked switch every chance to race the retraction: without a + // guard it re-keys within a few ticks of the lease release, while the + // guarded path stays parked until the gate below opens. + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch:'))), + delay(50), + ]); + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-other'); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const switchDone = driver.eventLog.findIndex((entry) => entry.startsWith('switch:')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + switchDone !== -1 && retractDone < switchDone, + 'the switch re-keys only after the retraction lands: ' + driver.eventLog.join(','), + ); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must reach the editor instead of being discarded by the switched-session fence', + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('discards a retraction that lands after a mid-turn session switch', async () => { const terminal = new FakeTerminal(); const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); From b48832dbf714bb4d8454677e1238e4203dcf0d7a Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 29 Sep 2026 06:32:34 +0800 Subject: [PATCH 17/22] fix(cli): block Alt+Up retractions for the entire in-progress switch window Seventh-round review (#5265 review) flagged that the idle switch's `settleRetractions()` drain only covers retractions asked for before the switch starts. `driver.switchSession()` is asynchronous - it stops user commands and opens the target Session channel before it re-keys - and Alt+Up stays live through that whole window. A retraction pressed there captures the old session id, and when the driver re-keys before the retract response arrives, the switched-session fence discards the returned text and quotes after the Host already removed the queued entries: a real loss. Count every runner-level switch (idle `/session`, mid-turn detach, and rewind's branch-and-switch) in a switch window and make `retractQueuedMessages` drop keypresses while one is open. Swallowing the keypress loses nothing: the entries stay queued on the Host and Alt+Up works once the switch lands, retracting from the session that is then current. The regression test parks the fake driver inside `switchSession`, before its re-key, and fails on the previous head with a `queue.retract` crossing the window (`retractCalls` 1 !== 0); it passes with the gate and fails again with the gate removed (ablation). The four previous focused retraction tests, the rewind in-flight tests, and the full runner suite (241 tests, only the two known pre-existing SIGTERM failures) pass individually. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 78 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 79 ++++++++++++++----- 2 files changed, 137 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 5e4d340b9d..62fad6e9cd 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8527,6 +8527,78 @@ Slug openai-work ]); }); + test('blocks Alt+Up for the entire in-progress switch window, not just its drain', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Park the idle `/session` INSIDE the driver's switch, before it re-keys: + // the real driver spends this window stopping user commands and opening + // the target Session channel, and Alt+Up stays live throughout (#5265 + // review). + driver.switchGate = deferred(); + driver.retractGate = deferred(); + terminal.input('/session session-other'); + terminal.input('\r'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch-start:'))); + + // Alt+Up in that window must not send a retraction for the old Session: + // the Host would remove the queued entries while the driver re-keys, and + // the switched-session fence would then discard what the Host removed. + terminal.input('\x1b[1;3A'); // Alt+Up + await delay(50); + assert.equal( + driver.retractCalls, + 0, + 'no queue.retract may cross while the switch is still re-keying', + ); + + // Re-key while a retraction response would still be pending, then let any + // response land: the queued entries must survive untouched either way. + driver.switchGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-other'); + driver.retractGate.resolve(); + await delay(30); + assert.equal(driver.queuedRows.length, 1, 'the queued entry stays on the Host queue'); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), + 'no retraction completed: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -14002,6 +14074,7 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { readonly eventLog: string[] = []; retractGate: ReturnType> | undefined = undefined; enqueueGate: ReturnType> | undefined = undefined; + switchGate: ReturnType> | undefined = undefined; #retractCalls = 0; #stopCalls = 0; #turnStarted = false; @@ -14059,6 +14132,11 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { override async switchSession(sessionId: string): Promise { // The base fake leaves `sessionId` alone; a mid-turn switch must move the // driver's session for the retraction-fence scenario to be reachable. + this.eventLog.push(`switch-start:${sessionId}`); + // The real driver spends several asynchronous calls (stopping user + // commands, opening the target Session channel) before it re-keys; the + // gate holds a test inside that in-progress switch window. + if (this.switchGate) await this.switchGate.promise; this.eventLog.push(`switch:${sessionId}`); this.sessionId = sessionId; return super.switchSession(sessionId); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 1cdbd52744..b724ef21e4 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1305,6 +1305,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { await Promise.allSettled([...pendingRetractionTasks]); } }; + // A session switch re-keys the driver only partway through its work: the + // driver stops user commands and opens the target Session's channel before + // adopting the new id, and Alt+Up stays live through that whole window. A + // retraction asked for there starts after the switch's `settleRetractions` + // drain yet still addresses the old Session — the Host removes the queued + // entries while the driver re-keys, and the switched-session fence then + // discards the returned text and quotes. New retractions are blocked for + // the entire switch instead (#5265 review). + let sessionSwitchesInFlight = 0; + const holdSwitchWindow = async (body: () => Promise): Promise => { + sessionSwitchesInFlight += 1; + try { + return await body(); + } finally { + sessionSwitchesInFlight -= 1; + } + }; const requestTurnInterrupt = () => { // A detach in flight is not the running Turn's owner acting on it — the @@ -1551,6 +1568,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Alt+↑: take back every queued message from the Runtime Host, joined and // prepended to the current draft for re-editing. const retractQueuedMessages = () => { + // A session switch blocks new retractions for its entire window: the + // switch's own settle drain only covers retractions asked for before it, + // and past that drain a retraction would still address the old Session + // while the driver is busy re-keying (#5265 review). Swallowing the + // keypress loses nothing: the entries stay queued and Alt+Up works once + // the switch lands. + if (sessionSwitchesInFlight > 0) return; const retractionTask = (async () => { // Same session fence as the interrupt path: a mid-turn `/session` that // lands while enqueues or the retraction are in flight must neither @@ -2034,7 +2058,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // The driver validates the durable cwd before adopting the resumed session. // A failure leaves the active session untouched and the next prompt still // lands on the old one. - const switchSession = async (sessionId: string, relocateCwd?: string) => { + const runSessionSwitch = async (sessionId: string, relocateCwd?: string) => { // A session switch waits for an in-flight retraction: the retracted text // and quotes must land in the session they were asked for before the // driver re-keys (#5109 review). @@ -2082,6 +2106,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } requestRender(); }; + // The whole switch — its settle drain, the driver's asynchronous re-key, + // and the adoption — runs inside the switch window so Alt+Up cannot send a + // retraction for the session being left (#5265 review). + const switchSession = (sessionId: string, relocateCwd?: string): Promise => + holdSwitchWindow(() => runSessionSwitch(sessionId, relocateCwd)); // Mid-turn `/session` switch-away (#3380): adopt another Session while a // Turn is still running on the current one. In Runtime Host mode the Turn is @@ -2091,7 +2120,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // tail unwinds through the superseded branch and releases busy/activity, // then either that tail or the startPendingAttachedTurn below starts the // freshly attached Turn, whichever observes an idle runner first. - const switchAwayMidTurn = async (sessionId: string) => { + const runMidTurnSwitch = async (sessionId: string) => { // Same serialization as the idle switch: the in-flight retraction lands // its payload in the session it was asked for first (#5109 review). await settleRetractions(); @@ -2138,6 +2167,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { startPendingAttachedTurn(); } }; + // Same window as the idle switch: the mid-turn detach also re-keys the + // driver through asynchronous work, so Alt+Up waits it out (#5265 review). + const switchAwayMidTurn = (sessionId: string): Promise => + holdSwitchWindow(() => runMidTurnSwitch(sessionId)); const stopSideParentObserver = async ( pair: NonNullable, @@ -2355,25 +2388,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push(pendingNotice); requestRender(); try { - const result = await input.driver.rewindToTurn(turnId).catch((error: unknown) => { - // The driver refuses rewind with a machine code when the selected - // turn carries structured context the TUI cannot restore (#5109). - // Render the localized catalog copy for that code instead of the - // driver's English fallback. - const code = (error as { code?: unknown })?.code; - if ( - code === 'rewind_unsupported_attachments' || - code === 'rewind_unsupported_directory_references' - ) { - const localized = - code === 'rewind_unsupported_attachments' - ? TUI_REWIND_COPY[locale].unsupportedAttachments - : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; - throw new Error(localized); - } - throw error; + // Same switch window as /session: rewind re-keys the driver through its + // own asynchronous branch-and-switch, and a retraction crossing that + // window would address the session being left (#5265 review). + const result = await holdSwitchWindow(async () => { + const rewind = await input.driver.rewindToTurn(turnId).catch((error: unknown) => { + // The driver refuses rewind with a machine code when the selected + // turn carries structured context the TUI cannot restore (#5109). + // Render the localized catalog copy for that code instead of the + // driver's English fallback. + const code = (error as { code?: unknown })?.code; + if ( + code === 'rewind_unsupported_attachments' || + code === 'rewind_unsupported_directory_references' + ) { + const localized = + code === 'rewind_unsupported_attachments' + ? TUI_REWIND_COPY[locale].unsupportedAttachments + : TUI_REWIND_COPY[locale].unsupportedDirectoryReferences; + throw new Error(localized); + } + throw error; + }); + await applySwitchResult(rewind); + return rewind; }); - 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 From 0ac4a7ef9c1f09563ab101116d858d9667bc6507 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 30 Sep 2026 06:48:23 +0800 Subject: [PATCH 18/22] fix(cli): hold the switch window across side-conversation re-keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side-conversation open and close re-key the driver through the same internal switchSession path as `/session` (Runtime Host openSideConversation switches onto the forked side Session, closeSideConversation switches back onto the parent), but neither entered the sessionSwitchesInFlight window, so Alt+Up stayed live across the re-key. A retraction asked inside that window removes the parent's queued entries via queue.retract while the driver is re-keying, and the response-time session fence then discards the returned text and quotes — the same silent input loss already fixed for `/session`, mid-turn detach, and rewind at the current head. Hold the same window around both switch-owning operations: the open wraps its adopt (both the idle runControl path and the mid-turn detach path) and the close wraps its whole body behind the existing closeSideConversation entry points. The window swallows the keypress without loss: the entries stay on the Host queue and Alt+Up works once the re-key lands. Regression tests park a gated fake driver inside the open's and close's re-key, fire Alt+Up there, and assert no queue.retract crosses (retractCalls stays 0) while the queued entry survives untouched. Both fail on the unfixed head (retractCalls 1 !== 0) and pass with the guard; removing only the two new holdSwitchWindow wrappers brings both back to red. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 197 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 16 +- 2 files changed, 210 insertions(+), 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 62fad6e9cd..47a348dd22 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8599,6 +8599,151 @@ Slug openai-work ]); }); + test('blocks Alt+Up while a side conversation is still opening', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Park `/side` inside the driver's open, before it re-keys onto the side + // Session: the real driver spends this window forking the parent and + // switching onto the fork, and Alt+Up stays live throughout (#5265 + // review). + driver.openGate = deferred(); + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))); + + // Alt+Up in that window must not send a retraction for the parent + // Session: the Host would remove its queued entries while the driver + // re-keys, and the side-session fence would then discard what the Host + // removed. + terminal.input('\x1b[1;3A'); // Alt+Up + await delay(50); + assert.equal( + driver.retractCalls, + 0, + 'no queue.retract may cross while the side open is still re-keying', + ); + + // Let the open land: the queued entry must survive untouched either way. + driver.openGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + await delay(30); + assert.equal(driver.queuedRows.length, 1, 'the queued entry stays on the Host queue'); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), + 'no retraction completed: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('blocks Alt+Up while a side conversation is still closing', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Open for real (ungated), then park the close inside the driver's re-key + // back onto the parent: the real close spends this window switching onto + // the parent Session before it tears the side copy down (#5265 review). + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + + driver.closeGate = deferred(); + terminal.input('\x03'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))); + + // Alt+Up in that window would retract the parent's queued entry under the + // side Session's identity; the close's fence would then discard the + // returned text and quotes while the entries are gone from the Host. + terminal.input('\x1b[1;3A'); // Alt+Up + await delay(50); + assert.equal( + driver.retractCalls, + 0, + 'no queue.retract may cross while the side close is still re-keying', + ); + + // Let the close land: the queued entry must survive untouched either way. + driver.closeGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-branch'); + await waitFor(() => driver.closedSides.length === 1); + await delay(30); + assert.equal(driver.queuedRows.length, 1, 'the queued entry stays on the Host queue'); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('retract-done:')), + 'no retraction completed: ' + driver.eventLog.join(','), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -14143,6 +14288,58 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { } } +/** + * Adds side-conversation re-keys to the retracting driver: `openGate` and + * `closeGate` hold a test inside the open's or close's in-progress re-key, + * the same parking spot `switchGate` provides for `/session` (#5265 review). + */ +class RetractingSideConversationDriver extends RetractingQuotesDriver { + openGate: ReturnType> | undefined = undefined; + closeGate: ReturnType> | undefined = undefined; + #parentStatusListener: + | ((status: MakaSideConversationParentStatus | undefined) => void) + | undefined; + readonly closedSides: Array<{ sideSessionId: string; parentSessionId: string }> = []; + + async openSideConversation() { + const parentSessionId = this.sessionId; + this.eventLog.push(`open-start:${parentSessionId}`); + if (this.openGate) await this.openGate.promise; + this.eventLog.push('open:side-1'); + this.sessionId = 'side-1'; + return { + summary: { + ...fakeSessionSummary('side-1'), + labels: ['mode:side_conversation'], + parentSessionId, + branchOfTurnId: 'turn-settled', + }, + messages: [], + parentSessionId, + sideSessionId: 'side-1', + }; + } + + async closeSideConversation(sideSessionId: string, parentSessionId: string) { + this.eventLog.push(`close-start:${sideSessionId}`); + if (this.closeGate) await this.closeGate.promise; + this.eventLog.push(`close:${parentSessionId}`); + this.sessionId = parentSessionId; + this.closedSides.push({ sideSessionId, parentSessionId }); + return { ...switchResult(fakeSessionSummary(parentSessionId)), cleanup: 'removed' as const }; + } + + async observeSideConversationParent( + _parentSessionId: string, + listener: (status: MakaSideConversationParentStatus | undefined) => void, + ) { + this.#parentStatusListener = listener; + return async () => { + if (this.#parentStatusListener === listener) this.#parentStatusListener = undefined; + }; + } +} + /** * 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 b724ef21e4..83e4f84497 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2318,11 +2318,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); requestRender(); }; + // Opening re-keys the driver onto the side Session through asynchronous + // work (the Host forks the parent and switches onto the fork), the same + // switchSession re-key path as `/session`, so both entry paths hold the + // switch window: a retraction asked inside it would address the parent + // while the Host removes its queued entries, and the side-session fence + // would then discard what the Host removed (#5265 review). if (turnRunning) { if (detaching) return; detaching = true; try { - await adopt(); + await holdSwitchWindow(adopt); } catch (error) { reportError(error); } finally { @@ -2330,14 +2336,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { startPendingAttachedTurn(); } } else { - await runControl(adopt); + await runControl(() => holdSwitchWindow(adopt)); } if (!opened || !prompt) return; await previousActivity?.catch(() => undefined); submitPrompt(prompt); }; - const closeSideConversation = async (): Promise => { + const runCloseSideConversation = async (): Promise => { const pair = sideConversation; if (!pair || !input.driver.closeSideConversation) return; const result = await input.driver.closeSideConversation( @@ -2357,6 +2363,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } requestRender(); }; + // Same window as the open: closing re-keys the driver back onto the parent + // Session through its own asynchronous switch, so Alt+Up waits it out + // (#5265 review). + const closeSideConversation = (): Promise => holdSwitchWindow(runCloseSideConversation); const interruptAndCloseSideConversation = (): void => { if (interruptRequested) return; const completion = currentActivityCompletion; From 0f26b77c7f24c4d554beaa22abf6f8a0d5c0106d Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 30 Sep 2026 08:19:09 +0800 Subject: [PATCH 19/22] fix(cli): drain in-flight retractions before side-conversation re-keys holdSwitchWindow only blocks Alt+Up requests made inside a switch window; a retraction already in flight when /side, a side close, or a rewind started could still resolve after the driver re-keyed. The Host removes the queued entries as the retraction resolves, and the response-time session fence then discards the returned text and quotes - reachable by queuing a follow-up, pressing Alt+Up (retraction pending), and running the command before it settles; the reviewer reproduced the loss through real TUI key/command paths (#5265 review, P2). Copy the `/session` pattern (drain pendingRetractionTasks inside the held window, before the driver re-key starts) into the three remaining re-keying paths: the side open's adopt (idle runControl path and mid-turn detach path alike), the side close's runCloseSideConversation, and the rewind's branch-and-switch. Rewind had the same disease: every rewind re-keys onto a fresh branch Session, so a pending retraction crosses a session change and the branch fence discards its response exactly like the side fences. Tests park a gated retraction on the fake driver's Host call, start the open / close / rewind behind it, and assert the re-key waits: retract-done lands before the re-key and the retracted text reaches the editor. The rewind driver mints a fresh branch id per rewind so the second rewind crosses a session change the way the real driver does. All four tests fail on the unfixed runner (re-key observed while the retraction is still pending), pass with the fix, and fail again with only the three settleRetractions calls removed. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 325 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 22 +- 2 files changed, 344 insertions(+), 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 47a348dd22..b40f0dca7e 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8744,6 +8744,302 @@ Slug openai-work ]); }); + test('drains a retraction started before an idle side conversation opens', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // End the turn: the runner is idle while the Host still holds the queued + // message and the quote riding it. + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Alt+Up retraction is held on the Host call when `/side` arrives: + // the open must drain it inside its switch window. The Host removes the + // parent's queued entries as the retraction resolves, so a response + // landing after the re-key onto the side Session would be discarded by + // the side-session fence (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('/side'); + terminal.input('\r'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('open-start:')), + 'the side open parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const openDone = driver.eventLog.findIndex((entry) => entry.startsWith('open:side-1')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + openDone !== -1 && retractDone < openDone, + 'the side open re-keys only after the retraction lands: ' + driver.eventLog.join(','), + ); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must reach the editor instead of being discarded by the side-session fence', + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('drains a retraction started before a mid-turn side conversation opens', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + // Same reachable order as the idle open, with the Turn still running: + // Alt+Up parks on the Host call and the mid-turn `/side` detaches behind + // it. The detach re-keys onto the side Session, so the open must drain + // the retraction first or its response is fenced away (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('/side'); + terminal.input('\r'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('open-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('open-start:')), + 'the mid-turn side open parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const openDone = driver.eventLog.findIndex((entry) => entry.startsWith('open:side-1')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + openDone !== -1 && retractDone < openDone, + 'the mid-turn side open re-keys only after the retraction lands: ' + + driver.eventLog.join(','), + ); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must reach the editor instead of being discarded by the side-session fence', + ); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + driver.turnGate.resolve(); + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('drains a retraction started before a side conversation closes', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + // Open for real, then run a side Turn and queue onto it, and leave the + // runner idle with the entry still on the side Session's Host queue. + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('side follow-up'); + terminal.input('\r'); + await waitFor(() => driver.queuedRows.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Alt+Up retraction is held on the Host call when the close arrives: + // the close must drain it before re-keying onto the parent, or the + // close's session fence discards the response for entries the Host + // already removed (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('\x03'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('close-start:')), + 'the side close parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.closedSides.length === 1); + + const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); + const closeDone = driver.eventLog.findIndex((entry) => entry.startsWith('close:')); + assert.ok(retractDone !== -1, 'the retraction completed'); + assert.ok( + closeDone !== -1 && retractDone < closeDone, + 'the side close re-keys only after the retraction lands: ' + driver.eventLog.join(','), + ); + assert.equal(driver.getSessionId(), 'session-branch'); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('drains a retraction started before a rewind branches', async () => { + const terminal = new FakeTerminal(); + const driver = new PerRewindBranchRetractingDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The Alt+Up retraction is held on the Host call when a second rewind is + // selected: each rewind branches onto a fresh Session, so the pending + // retraction crosses a re-key and the branch-switch fence would discard + // what the Host already removed from the first branch's queue (#5265 + // review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => driver.pickerOpens === 2); + terminal.input('\r'); + await Promise.race([waitFor(() => driver.rewound.length === 2), delay(50)]); + assert.equal( + driver.rewound.length, + 1, + 'the rewind parks behind the pending retraction instead of branching under it', + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.rewound.length === 2); + await waitFor(() => driver.getSessionId() === 'session-branch-2'); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must survive the rewind instead of being discarded by the branch fence', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + test('shows an in-progress notice while the rewind branch is being created', async () => { const terminal = new FakeTerminal(); const driver = new DeferredRewindDriver( @@ -14340,6 +14636,35 @@ class RetractingSideConversationDriver extends RetractingQuotesDriver { } } +/** + * Each rewind branches onto a fresh session id, the way the real driver mints + * a new branch per rewind: a retraction asked before a second rewind crosses a + * session change, so the branch-switch fence applies to its response (#5265 + * review). + */ +class PerRewindBranchRetractingDriver extends RetractingQuotesDriver { + #rewindSeq = 0; + #pickerOpens = 0; + + override async listRewindTargets(): Promise { + this.#pickerOpens += 1; + return super.listRewindTargets(); + } + + /** How many times the rewind picker opened; picker renders share labels with + * the transcript, so scrollback text alone cannot tell two openings apart. */ + get pickerOpens(): number { + return this.#pickerOpens; + } + + override async rewindToTurn(turnId: string): Promise { + const result = await super.rewindToTurn(turnId); + this.#rewindSeq += 1; + this.sessionId = `session-branch-${this.#rewindSeq}`; + return { ...result, summary: fakeSessionSummary(this.sessionId) }; + } +} + /** * 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 83e4f84497..aebe41f858 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2300,6 +2300,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const previousActivity = currentActivityCompletion; let opened = false; const adopt = async () => { + // The window blocks only retractions asked inside it: one already in + // flight when the open started must drain here, while the driver still + // points at the parent — the Host removes the parent's queued entries as + // the retraction resolves, and a response landing after the re-key onto + // the side Session would be discarded by the side-session fence (#5265 + // review). + await settleRetractions(); const result = await input.driver.openSideConversation!(); if (turnRunning) turnEpoch += 1; await applySwitchResult(result); @@ -2346,6 +2353,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const runCloseSideConversation = async (): Promise => { const pair = sideConversation; if (!pair || !input.driver.closeSideConversation) return; + // Same drain as the open: a retraction asked while the side Session was + // active must land while the driver still points at it — past the re-key + // onto the parent, the close's session fence discards the text and quotes + // the Host already removed from the side queue (#5265 review). + await settleRetractions(); const result = await input.driver.closeSideConversation( pair.sideSessionId, pair.parentSessionId, @@ -2398,10 +2410,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push(pendingNotice); requestRender(); try { - // Same switch window as /session: rewind re-keys the driver through its - // own asynchronous branch-and-switch, and a retraction crossing that - // window would address the session being left (#5265 review). const result = await holdSwitchWindow(async () => { + // Same switch window as /session: rewind re-keys the driver through + // its own asynchronous branch-and-switch, and a retraction crossing + // that window would address the session being left (#5265 review). + // That window blocks only retractions asked inside it — one already in + // flight drains here, before the branch re-keys onto a fresh Session + // whose fence would otherwise discard the response (#5265 review). + await settleRetractions(); const rewind = await input.driver.rewindToTurn(turnId).catch((error: unknown) => { // The driver refuses rewind with a machine code when the selected // turn carries structured context the TUI cannot restore (#5109). From bd25e82fc2044fee586c8a16660129fc88417605 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 30 Sep 2026 10:44:23 +0800 Subject: [PATCH 20/22] fix(cli): keep a drained retraction's payload out of the wrong editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth-round review (#5265 review) confirmed the drains order the Host retraction before each re-key but flagged two inline P2s in what happens to the restored payload afterwards: - Side open: after the drain, acceptRetraction() has already put the parent's recovered text into the shared editor; the open then recorded it as parentDraft but never swapped the editor to the side view's own (empty) draft, so the parent's message sat in the side editor where Enter would resubmit it inside the side conversation. The open now switches the editor to the side draft after capturing parentDraft, per the established per-view draft contract. - Side close: the Ctrl+C close guard admits the close against an empty editor, and the drain then restores the side's queued text into it — which the following editor.setText(parentDraft) overwrote, losing the recovered message. The close now aborts with a notice when the editor is non-empty after the drain: closing is only admitted from an empty draft, so anything there came from the drain (or typing under it), and a later Ctrl+C clears it like any draft. Both behaviors were red under the reviewer's own reproductions (parent text visible in the new side editor; side text absent after close) and green with the guards; the three updated regressions also failed with only the guards removed. Full runner suite 245/247 - the two failures are the known pre-existing SIGTERM tests. check:tui-copy (new notice literal registered) and check:locale-hygiene pass. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 53 ++++++++++++++----- packages/cli/src/pi-tui-runner.ts | 20 +++++++ scripts/check-tui-copy.mjs | 1 + 3 files changed, 61 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 b40f0dca7e..3e085040d0 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8805,13 +8805,27 @@ Slug openai-work openDone !== -1 && retractDone < openDone, 'the side open re-keys only after the retraction lands: ' + driver.eventLog.join(','), ); - assert.ok( - editorInputText(terminal)?.includes('queued resend') === true, - 'the retracted text must reach the editor instead of being discarded by the side-session fence', + assert.equal( + editorInputText(terminal) ?? '', + '', + 'the side editor must open on the side draft, not the recovered parent text', ); assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, ]); + + // Closing again returns to the parent, whose draft owns the recovered + // text: it was captured while the drain's restore was still in the + // editor, and the side view never sees it (#5265 review). + terminal.input('\x03'); + await waitFor(() => driver.getSessionId() === 'session-branch'); + assert.equal(driver.closedSides.length, 1); + // The editor restore lands after the driver re-keys, so wait for the + // content itself rather than asserting behind the session-id wait. + await waitFor( + () => editorInputText(terminal)?.includes('queued resend') === true, + 'the recovered parent text must survive the side round trip in the parent draft', + ); } finally { exitMaka(terminal); await Promise.race([ @@ -8879,9 +8893,10 @@ Slug openai-work 'the mid-turn side open re-keys only after the retraction lands: ' + driver.eventLog.join(','), ); - assert.ok( - editorInputText(terminal)?.includes('queued resend') === true, - 'the retracted text must reach the editor instead of being discarded by the side-session fence', + assert.equal( + editorInputText(terminal) ?? '', + '', + 'the mid-turn side editor must open on the side draft, not the recovered parent text', ); assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, @@ -8951,16 +8966,28 @@ Slug openai-work ); driver.retractGate.resolve(); - await waitFor(() => driver.closedSides.length === 1); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('retract-done:'))); - const retractDone = driver.eventLog.findIndex((entry) => entry.startsWith('retract-done:')); - const closeDone = driver.eventLog.findIndex((entry) => entry.startsWith('close:')); - assert.ok(retractDone !== -1, 'the retraction completed'); + // The close was admitted against an empty draft, so the text the drain + // restored must abort it: closing here would overwrite the recovered + // side message with the parent draft and lose it (#5265 review). + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Side conversation kept open'), + ); + assert.equal( + driver.closedSides.length, + 0, + 'the close must not proceed once the drain restored a draft', + ); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('close:')), + 'no close re-key may happen after the drain restored text: ' + driver.eventLog.join(','), + ); + assert.equal(driver.getSessionId(), 'side-1'); assert.ok( - closeDone !== -1 && retractDone < closeDone, - 'the side close re-keys only after the retraction lands: ' + driver.eventLog.join(','), + editorInputText(terminal)?.includes('side follow-up') === true, + 'the recovered side text stays visible in the side editor', ); - assert.equal(driver.getSessionId(), 'session-branch'); } finally { exitMaka(terminal); await Promise.race([ diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index aebe41f858..ea8aab2eac 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2316,6 +2316,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { parentDraft: editor.getText(), sideDraft: '', }; + // The drain may have restored the parent's recovered text into the + // editor before the re-key; it is captured as parentDraft above, and the + // editor switches to the side view's own (empty) draft — pressing Enter + // here must not resubmit the parent's message inside the side + // conversation (#5265 review). + editor.setText(sideConversation.sideDraft); await startSideParentObserver(sideConversation); opened = true; state.entries.push({ @@ -2358,6 +2364,20 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // onto the parent, the close's session fence discards the text and quotes // the Host already removed from the side queue (#5265 review). await settleRetractions(); + // The close is only admitted from an empty draft, so anything in the + // editor here was restored by the drain (or typed under it): aborting + // keeps the recovered side text visible instead of overwriting it with + // the parent draft below, and a later Ctrl+C clears it like any draft + // (#5265 review). + if (editor.getText().length > 0) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'Side conversation kept open — the retracted message was restored to the draft.', + }); + requestRender(); + return; + } const result = await input.driver.closeSideConversation( pair.sideSessionId, pair.parentSessionId, diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs index 6e054d2c62..752ab9eda3 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -93,6 +93,7 @@ export const ALLOWED_VISIBLE_LITERALS = { 'Close the current side conversation before opening another.', 'Side conversations are unavailable on this runtime.', 'Side conversation opened.', + 'Side conversation kept open — the retracted message was restored to the draft.', 'Side conversation closed; cleanup will be retried on the next launch.', '↑↓ move · type to answer · Enter select · Esc unanswered · Ctrl+C stop', 'Other: type your answer…', From 5e538681b1f84439796923b06f977f32cf40ea5a Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 30 Sep 2026 15:41:13 +0800 Subject: [PATCH 21/22] fix(cli): keep retraction-restored quotes and close-window typing from dying in the switch paths Ninth-round review (#5265 review) flagged three P2s and three P3s in what happens to a drained retraction's payload after the drain: - Quotes lost across sessions (P2): every switch path cleared the staging a drained retraction had just staged, and the Host already removed the queue entries the quotes came from - the TUI copy was the only one, so side open, /session, a quote-only side close, and a rewind all lost them. Retraction-restored quotes now ride the recovered draft text, wherever it goes: applySwitchResult still clears turn-staged quotes outright (the #5109 rule stands) but spares the draft-riding lane; the side conversation's draft slots carry quotes alongside their text (parked on toggle/open, staged back for the owning view on return); a rewind that branches without its own quotes keeps them; a rewind that carries its own quotes still replaces them (#5109 semantics, now visible and pinned by a test with distinct quotes per side). - Ctrl+/ lost the recovered text (P2): the toggle captured the draft before waiting a pending retraction out, then wrote the stale draft back over what the drain had restored - side editor, parent editor, and Host queue all empty with no notice. The capture moved after the switch's drain, like the side open. - The close window overwrote live typing (P2): a close admitted against an empty editor left input unlocked while its driver call was in flight, then setText(parentDraft) discarded whatever was typed during the wait. The live editor content now outranks the parked parent draft (kept, parent draft appended below when both exist). - Quote-only close (P3): the abort guard only looked at editor text, so a retraction returning quotes with no text still closed and dropped the staging; staged quotes now count as a keep-open reason. - Notice wording (P3): the kept-open notice no longer claims a retracted message for what may be the user's own typing - two neutral literals replace the old one (check:tui-copy registry updated). - Rewind test (P3): the drain-before-rewind test minted the same quote on both sides, so the designed replacement could not be distinguished from survival; each rewind now carries a distinct quote and the resubmit asserts the rewound turn's quote won. Every finding verified red under the reviewer's reproduction on the pre-fix runner and green after; new coverage asserts what the TUI actually submitted (driver.submittedQuotes) across the side round trip, the /session switch, and the quote-less rewind. Full pi-tui-runner suite: 252 pass / 2 known pre-existing SIGTERM failures. check:tui-copy, check:locale-hygiene (vs upstream/main), check:asf-headers, and biome on the touched files pass; merge-tree against upstream/main is clean. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 484 +++++++++++++++++- packages/cli/src/pi-tui-runner.ts | 127 ++++- scripts/check-tui-copy.mjs | 3 +- 3 files changed, 587 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 3e085040d0..42dd5a8c32 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -9025,6 +9025,10 @@ Slug openai-work terminal.input('queued resend'); terminal.input('\r'); await waitFor(() => driver.submittedQuotes.length === 1); + // The first rewind staged its own quote and the queued submit rode it. + assert.deepEqual(driver.submittedQuotes[0], [ + { text: 'excerpt from rewind 1', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); driver.turnGate.resolve(); await waitFor(() => terminal.progressStates.at(-1) === false); @@ -9056,6 +9060,457 @@ Slug openai-work editorInputText(terminal)?.includes('queued resend') === true, 'the retracted text must survive the rewind instead of being discarded by the branch fence', ); + + // Resubmitting pins whose quotes the replacement carries: the second + // rewind's own quote replaced the retracted message's quote — rewind + // quotes are a replacement, not an accumulation (#5109, #5265 review). + // Distinct quotes per rewind are what makes the replacement visible. + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'excerpt from rewind 2', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('carries retraction-restored quotes through a side conversation round trip', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The open drains the retraction inside its switch window: the Host + // removes the parent's queued entries as the retraction resolves, so the + // quotes it returns are the only copy left. They must ride the parent + // draft through the side round trip instead of dying on the switch's + // staging clear (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('/side'); + terminal.input('\r'); + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'side-1'); + assert.deepEqual(driver.retractedQuoteLoads.at(-1), [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + + // Close again: the parent draft and its quotes come back together, so + // the resubmit still carries the recovered quotes. + terminal.input('\x03'); + await waitFor(() => driver.getSessionId() === 'session-branch'); + await waitFor( + () => editorInputText(terminal)?.includes('queued resend') === true, + 'the recovered parent text must come back with the close', + ); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('carries retraction-restored quotes across a /session switch', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingQuotesDriver([{ turnId: 'turn-1', label: 'first question' }]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The switch drains the held retraction before it re-keys: the recovered + // text reaches the editor and its quotes are staged for the session the + // retraction was asked in. The switch still clears turn-staged quotes + // outright, but these ride the recovered text — which the editor keeps + // across the switch — so they must survive, keyed to wherever the text + // now lives (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('/session session-other'); + terminal.input('\r'); + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('switch-start:')), + 'the switch parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-other'); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the recovered text reaches the editor across the switch', + ); + + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('keeps drained quotes when a rewind branches without quotes of its own', async () => { + const terminal = new FakeTerminal(); + const driver = new PerRewindBranchRetractingDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + // Only the first rewind carries quotes: the second branches quote-less, so + // nothing replaces the quotes the drain restored — they must survive with + // the recovered text instead of dying on the branch switch's clear (#5265 + // review). + driver.rewindQuotes = (seq) => + seq === 1 + ? [{ text: 'excerpt from rewind 1', label: 'earlier turn', sourceTurnId: 'turn-0' }] + : []; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => driver.pickerOpens === 2); + terminal.input('\r'); + driver.retractGate.resolve(); + await waitFor(() => driver.rewound.length === 2); + await waitFor(() => driver.getSessionId() === 'session-branch-2'); + assert.ok( + editorInputText(terminal)?.includes('queued resend') === true, + 'the retracted text must survive the rewind', + ); + + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'excerpt from rewind 1', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('captures the draft a retraction restores while a side toggle waits it out', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('side follow-up'); + terminal.input('\r'); + await waitFor(() => driver.queuedRows.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Ctrl+/ while the retraction is in flight: the toggle waits the + // retraction out and must capture the draft AFTER the drain restored it. + // Capturing before the wait writes the stale empty draft back over the + // recovered text — side editor, parent editor, and Host queue all end up + // empty with no notice (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + terminal.input('\x1f'); // Ctrl+/ — toggle to the parent + await Promise.race([ + waitFor(() => driver.eventLog.some((entry) => entry.startsWith('switch-start:'))), + delay(50), + ]); + assert.ok( + !driver.eventLog.some((entry) => entry.startsWith('switch-start:')), + 'the toggle parks behind the pending retraction: ' + driver.eventLog.join(','), + ); + + driver.retractGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-1'); + // Toggle back to the side view: its draft must hold the recovered text. + terminal.input('\x1f'); // Ctrl+/ — toggle back to the side + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor( + () => editorInputText(terminal)?.includes('side follow-up') === true, + 'the text the retraction restored during the toggle must survive in the side draft', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('keeps text typed while a side conversation close is in flight', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The close only disables submit for its window: typing still lands in + // the editor while the driver re-keys back to the parent. That live + // input wins over the parent draft — the unconditional + // setText(parentDraft) after the await threw it away (#5265 review). + driver.closeGate = deferred(); + terminal.input('\x03'); + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('close-start:'))); + terminal.input('typed while closing'); + driver.closeGate.resolve(); + await waitFor(() => driver.getSessionId() === 'session-1'); + assert.equal(driver.closedSides.length, 1); + await waitFor( + () => editorInputText(terminal)?.includes('typed while closing') === true, + 'text typed during the close window must survive the parent draft restore', + ); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('aborts a side close when a quote-only retraction left staged quotes', async () => { + const terminal = new FakeTerminal(); + const driver = new QuoteOnlyRetractingSideDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('side follow-up'); + terminal.input('\r'); + await waitFor(() => driver.queuedRows.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // Quote-only retraction: no text comes back, so the editor stays empty, + // but the quotes it returns are staged and ride the next side submit. A + // close that only checks the editor text would proceed and drop the + // staging on the switch (#5265 review). + driver.quoteOnlyRetraction = [ + { text: 'side quote', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]; + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.eventLog.some((entry) => entry.startsWith('retract-done:'))); + assert.equal(editorInputText(terminal) ?? '', '', 'a quote-only retraction restores no text'); + + terminal.input('\x03'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Side conversation kept open — the staged quotes were kept.', + ), + ); + assert.equal(driver.closedSides.length, 0, 'the staged quotes must abort the close'); + assert.equal(driver.getSessionId(), 'side-1'); + + // The staged quotes still ride the next side submit. + terminal.input('resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + assert.deepEqual(driver.submittedQuotes[1], [ + { text: 'side quote', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + + test('does not blame a retraction for a draft the user typed when keeping the side open', async () => { + const terminal = new FakeTerminal(); + const driver = new RetractingSideConversationDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The close is admitted against an empty editor, then parks on its + // drain. Nothing is queued, so the retraction will restore nothing — + // the text in the editor when the drain settles is the user's own + // typing from the wait window, and the kept-open notice must not + // attribute it to a retraction (#5265 review). + driver.retractGate = deferred(); + terminal.input('\x1b[1;3A'); // Alt+Up — queue is empty, call parks on the gate + await waitFor(() => driver.retractCalls === 1); + terminal.input('\x03'); + terminal.input('my own note'); + driver.retractGate.resolve(); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Side conversation kept open — the editor draft was kept.', + ), + ); + assert.equal( + plainTerminalOutput(terminal.output()).includes('retracted message'), + false, + 'the notice must not attribute a user-typed draft to a retraction', + ); + assert.equal(driver.closedSides.length, 0); + assert.equal(driver.getSessionId(), 'side-1'); + assert.ok( + editorInputText(terminal)?.includes('my own note') === true, + 'the user draft stays in the editor', + ); } finally { exitMaka(terminal); await Promise.race([ @@ -14672,6 +15127,13 @@ class RetractingSideConversationDriver extends RetractingQuotesDriver { class PerRewindBranchRetractingDriver extends RetractingQuotesDriver { #rewindSeq = 0; #pickerOpens = 0; + /** Quotes each rewind branches with; `seq` is 1-based. Each rewind mints a + * distinct quote, so a test can tell the rewound turn's own quotes from the + * ones a retraction restored — with one shared quote the replacement stays + * invisible (#5265 review). */ + rewindQuotes: (seq: number) => QuoteRef[] = (seq) => [ + { text: `excerpt from rewind ${seq}`, label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]; override async listRewindTargets(): Promise { this.#pickerOpens += 1; @@ -14688,7 +15150,27 @@ class PerRewindBranchRetractingDriver extends RetractingQuotesDriver { const result = await super.rewindToTurn(turnId); this.#rewindSeq += 1; this.sessionId = `session-branch-${this.#rewindSeq}`; - return { ...result, summary: fakeSessionSummary(this.sessionId) }; + return { + ...result, + summary: fakeSessionSummary(this.sessionId), + quotes: this.rewindQuotes(this.#rewindSeq), + }; + } +} + +/** + * A quote-only queued entry: the Host's retraction answers with quotes and no + * text, so the editor stays empty while the staging takes the quotes — the + * exact shape the close guard must not mistake for "nothing to keep" (#5265 + * review). + */ +class QuoteOnlyRetractingSideDriver extends RetractingSideConversationDriver { + quoteOnlyRetraction: QuoteRef[] = []; + + override async retractQueued(): Promise { + const retracted = await super.retractQueued(); + if (this.quoteOnlyRetraction.length === 0) return retracted; + return { text: '', messageIds: retracted.messageIds, quotes: [...this.quoteOnlyRetraction] }; } } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index ea8aab2eac..6990866803 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -653,6 +653,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { readonly sideSessionId: string; parentDraft: string; sideDraft: string; + // Quotes recovered by a retraction ride the draft they were restored + // into: parked here while the other view is active, staged back when + // the view returns (#5265 review). + parentQuotes: StagedQuoteRefs; + sideQuotes: StagedQuoteRefs; parentStatus?: MakaSideConversationParentStatus; stopParentObserver?: () => Promise; } @@ -701,11 +706,22 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // 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 = []; + // + // Retraction-restored quotes (#5265 review) are the exception: they come + // back attached to the retracted message's text, whose only remaining copy + // is the editor/draft the restore handed it to, so they follow that text — + // into the staging (while it is in the editor) or into the side + // conversation's draft slots (when the text is parked there) — instead of + // dying on a switch. followDraft marks that lane; the session key still + // gates turn-staged quotes exactly as #5109 left it. + type StagedQuoteRefs = NonNullable; + let stagedRewindQuotes: StagedQuoteRefs = []; let stagedQuotesSessionId: string | null = null; + let stagedQuotesFollowDraft = false; let stagedGeneration = 0; const effectiveStagedQuotes = () => - stagedQuotesSessionId !== null && stagedQuotesSessionId === input.driver.getSessionId() + stagedQuotesFollowDraft || + (stagedQuotesSessionId !== null && stagedQuotesSessionId === input.driver.getSessionId()) ? stagedRewindQuotes : []; // Every write to the staging pair is a new generation. In-flight submits @@ -713,14 +729,29 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // 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, + quotes: StagedQuoteRefs, sessionId: string | null, + followDraft = false, ) => { stagedRewindQuotes = quotes; stagedQuotesSessionId = sessionId; + stagedQuotesFollowDraft = followDraft; stagedGeneration += 1; }; const clearStagedQuotes = () => setStagedQuotes([], null); + // Quotes riding the recovered draft text move with it: taking them clears + // the staging (the text is leaving the editor for a draft slot), staging + // them back re-arms them for the view that owns the text (#5265 review). + const takeDraftQuotes = (): StagedQuoteRefs => { + if (!stagedQuotesFollowDraft || stagedRewindQuotes.length === 0) return []; + const quotes = stagedRewindQuotes; + clearStagedQuotes(); + return quotes; + }; + const stageDraftQuotes = (quotes: StagedQuoteRefs, sessionId: string) => { + if (quotes.length === 0) return; + setStagedQuotes(quotes, sessionId, true); + }; // Some actions supersede a pending restoration without writing the staging // pair, because the staging is already empty: an ordinary submit that // carries no quotes, or an explicit `/quotes clear` that finds nothing. @@ -1438,9 +1469,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { refillEditorFromQueues(retracted.text); // The Host returns the full MessageContent with a retraction: quotes that // rode a queued or steered message come back with it and restage here, so - // the re-edited retry does not go out without them (#5109 review). + // the re-edited retry does not go out without them (#5109 review). They + // ride the restored draft text, so they follow it across switches and + // draft parking (#5265 review). if (retracted.quotes.length > 0) { - setStagedQuotes(retracted.quotes, input.driver.getSessionId()); + setStagedQuotes(retracted.quotes, input.driver.getSessionId(), true); } }; @@ -1472,11 +1505,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 originFollowDraft = stagedQuotesFollowDraft; const restageForRetry = (): boolean => { if (!staged.length) return false; if (input.driver.getSessionId() !== originSessionId) return false; if (stagedGeneration !== originGeneration) return false; - setStagedQuotes(staged, originSessionId); + setStagedQuotes(staged, originSessionId, originFollowDraft); return true; }; const task = input.driver @@ -2035,12 +2069,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }: MakaSessionSwitchResult): Promise => { resetTranscriptViewer(); closeTodoOverlay(); - // Every session change invalidates the staged rewind quotes outright: + // Every session change invalidates turn-staged rewind quotes outright: // keying the staging to its session only hides it while the user is // elsewhere, and a silent resurrection on return would send context the // user can no longer see (#5109 review). The rewind re-stages its own // quotes after this returns. - clearStagedQuotes(); + // + // Retraction-restored quotes are the exception (#5265 review): they belong + // to the recovered text the editor is still holding across this switch, + // and the Host already removed the queue entries they came from — the + // staging here is the only copy. They stay live, keyed to wherever that + // text lives next (this session's editor, or the draft slot a side + // toggle/open parks it in right after this returns). + if (!stagedQuotesFollowDraft) clearStagedQuotes(); adoptSessionMetadata(summary, false); replaceTranscript(messages); syncInteractionOverlays(); @@ -2226,18 +2267,30 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (!pair || detaching || (busy && !turnRunning)) return; const fromSide = input.driver.getSessionId() === pair.sideSessionId; const targetSessionId = fromSide ? pair.parentSessionId : pair.sideSessionId; - const currentDraft = editor.getText(); const switchView = async () => { if (turnRunning) await switchAwayMidTurn(targetSessionId); else await switchSession(targetSessionId); if (sideConversation !== pair) return; + // Capture after the switch: both switch paths drain a pending + // retraction first, and the drain may have restored text (and its + // quotes) into the editor. Capturing before the wait would write that + // stale draft back over the recovery — side editor, parent editor, and + // Host queue all empty with no notice (#5265 review). + const currentDraft = editor.getText(); + const currentQuotes = takeDraftQuotes(); if (fromSide) { pair.sideDraft = currentDraft; + pair.sideQuotes = currentQuotes; editor.setText(pair.parentDraft); + stageDraftQuotes(pair.parentQuotes, pair.parentSessionId); + pair.parentQuotes = []; await stopSideParentObserver(pair); } else { pair.parentDraft = currentDraft; + pair.parentQuotes = currentQuotes; editor.setText(pair.sideDraft); + stageDraftQuotes(pair.sideQuotes, pair.sideSessionId); + pair.sideQuotes = []; await startSideParentObserver(pair); } requestRender(); @@ -2314,13 +2367,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { parentSessionId: result.parentSessionId, sideSessionId: result.sideSessionId, parentDraft: editor.getText(), + // The drain may have restored the parent's recovered text — and the + // quotes riding it — into the editor before the re-key; both are + // captured here, and the editor switches to the side view's own + // (empty) draft — pressing Enter here must not resubmit the parent's + // message inside the side conversation (#5265 review). + parentQuotes: takeDraftQuotes(), sideDraft: '', + sideQuotes: [], }; - // The drain may have restored the parent's recovered text into the - // editor before the re-key; it is captured as parentDraft above, and the - // editor switches to the side view's own (empty) draft — pressing Enter - // here must not resubmit the parent's message inside the side - // conversation (#5265 review). editor.setText(sideConversation.sideDraft); await startSideParentObserver(sideConversation); opened = true; @@ -2364,16 +2419,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // onto the parent, the close's session fence discards the text and quotes // the Host already removed from the side queue (#5265 review). await settleRetractions(); - // The close is only admitted from an empty draft, so anything in the - // editor here was restored by the drain (or typed under it): aborting - // keeps the recovered side text visible instead of overwriting it with - // the parent draft below, and a later Ctrl+C clears it like any draft + // The close is only admitted from an empty editor with nothing staged: + // recovered text in the editor (or staged quotes with no text — a + // quote-only retraction) is the only copy left, and closing here would + // overwrite it with the parent draft below or clear it on the switch. + // Aborting keeps it visible; a later Ctrl+C clears it like any draft // (#5265 review). - if (editor.getText().length > 0) { + const keptDraft = editor.getText().length > 0; + const keptQuotes = effectiveStagedQuotes().length > 0; + if (keptDraft || keptQuotes) { state.entries.push({ kind: 'notice', level: 'info', - text: 'Side conversation kept open — the retracted message was restored to the draft.', + text: keptDraft + ? 'Side conversation kept open — the editor draft was kept.' + : 'Side conversation kept open — the staged quotes were kept.', }); requestRender(); return; @@ -2383,7 +2443,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { pair.parentSessionId, ); await applySwitchResult(result); - editor.setText(pair.parentDraft); + // The switch window only disabled submit: anything typed while the close + // was in flight is live user input and outranks the parked parent draft. + // Keep it — appending the parent draft below when both exist — instead of + // letting the restore silently drop it (#5265 review). + const liveDraft = editor.getText(); + editor.setText( + liveDraft.length > 0 + ? pair.parentDraft.length > 0 + ? `${liveDraft}\n\n${pair.parentDraft}` + : liveDraft + : pair.parentDraft, + ); + // The parent view's own quotes come back with its draft, still keyed to + // the session their text lives in (#5265 review). + stageDraftQuotes(pair.parentQuotes, pair.parentSessionId); await stopSideParentObserver(pair); sideConversation = undefined; if (result.cleanup === 'pending') { @@ -2460,11 +2534,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return rewind; }); 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(); + // The rewound turn's own quotes replace anything staged — including + // draft quotes a drained retraction restored: replacement, not + // accumulation (#5109, #5265 review). When the branch carries no quotes + // of its own, retraction-restored quotes stay staged and keep riding + // the recovered text, which the rewind preserved in the editor (#5265 + // review). if (result.quotes?.length) { + clearStagedQuotes(); setStagedQuotes(result.quotes, input.driver.getSessionId()); state.entries.push({ kind: 'notice', diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs index 752ab9eda3..05ae1490bc 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -93,7 +93,8 @@ export const ALLOWED_VISIBLE_LITERALS = { 'Close the current side conversation before opening another.', 'Side conversations are unavailable on this runtime.', 'Side conversation opened.', - 'Side conversation kept open — the retracted message was restored to the draft.', + 'Side conversation kept open — the editor draft was kept.', + 'Side conversation kept open — the staged quotes were kept.', 'Side conversation closed; cleanup will be retried on the next launch.', '↑↓ move · type to answer · Enter select · Esc unanswered · Ctrl+C stop', 'Other: type your answer…', From eb0f8e4edaafa54241aa06f1c96dd4a6a6e8eed1 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 30 Sep 2026 20:18:34 +0800 Subject: [PATCH 22/22] fix(cli): keep retry provenance across failed submissions and switches The last open P2 from the ninth-round review (#5265 review) is an ordering bug in the submit path's staging bookkeeping: - A submit captured the staged quotes' draft-following provenance AFTER its own clear: clearStagedQuotes() resets the flag, so for every quote-carrying submit the capture always read false. When the admission then failed (or resolved without a receipt), restageForRetry() rebuilt the staging as ordinary turn-staged quotes, and the next /session or side-view switch cleared them in applySwitchResult() - stranding the recovered text without its context, the exact loss the draft-following lane exists to prevent. The read now happens before the dispatch's clear, so a failed restage re-arms the quotes in the lane they were staged in and a later switch carries them with the draft. The generation guard keeps its post-clear read: the #5109 restage-fence semantics are untouched. New coverage pins the combination path no prior test reached: a retraction-restored quote submit whose admission fails, then a /session switch - the resubmit after the switch must still carry the quotes. Red under the reviewer's ordering on the pre-fix runner, green with the moved read, and red again with only the read moved back (ablation). Focused quote/retraction/switch/side pattern 47/47; full pi-tui-runner suite 253/255 with the two failures being the known pre-existing SIGTERM tests; biome on the touched files, check:tui-copy, and check:asf-headers pass. Generated-by: GLM-5.3-Flash (ZCode) --- .../cli/src/__tests__/pi-tui-runner.test.ts | 93 +++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 6 +- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 42dd5a8c32..50e4810e6a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -9219,6 +9219,78 @@ Slug openai-work } }); + test('keeps a failed retraction-restored quote restage across a later session switch', async () => { + const terminal = new FakeTerminal(); + const driver = new FailingRetryRetractingDriver([ + { turnId: 'turn-1', label: 'first question' }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + try { + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + driver.startBlockingTurn(); + await waitFor(() => terminal.progressStates.at(-1) === true); + terminal.input('queued resend'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 1); + driver.turnGate.resolve(); + await waitFor(() => terminal.progressStates.at(-1) === false); + + // The retraction restores the queued text and its quotes into the + // draft-following lane (#5265 review). + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => driver.retractCalls === 1); + await waitFor( + () => editorInputText(terminal)?.includes('queued resend') === true, + 'the retraction restores the queued text to the editor', + ); + + // The retry's admission fails: the restage must keep the quotes in the + // draft-following lane instead of demoting them to turn-staged — the + // next switch clears turn-staged quotes outright, stranding the + // recovered text without its context (#5265 review, second P2). + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 2); + driver.hold(new Error('admission outcome unknown')); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('admission outcome unknown'), + ); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + terminal.input('/session session-other'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'session-other'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1')); + + terminal.input('retry after switch'); + terminal.input('\r'); + await waitFor(() => driver.submittedQuotes.length === 3); + assert.deepEqual(driver.submittedQuotes[2], [ + { text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' }, + ]); + } finally { + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + } + }); + test('keeps drained quotes when a rewind branches without quotes of its own', async () => { const terminal = new FakeTerminal(); const driver = new PerRewindBranchRetractingDriver([ @@ -15066,6 +15138,27 @@ class RetractingQuotesDriver extends MidTurnQuotesDriver { } } +/** + * The queued entry rides out normally; once a retraction has drained, every + * later submit hangs until the test rejects it — the failed admission must + * restage the restored quotes without dropping their draft-following + * provenance (#5265 review). + */ +class FailingRetryRetractingDriver extends RetractingQuotesDriver { + hold!: (error: Error) => void; + + override submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + if (this.retractCalls === 0) return super.submitMessage(text, options); + this.submittedQuotes.push(options.quotes); + return new Promise((_, reject) => { + this.hold = () => reject(new Error('admission outcome unknown')); + }); + } +} + /** * Adds side-conversation re-keys to the retracting driver: `openGate` and * `closeGate` hold a test inside the open's or close's in-progress re-key, diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 6990866803..c9b9f08761 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1496,6 +1496,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // restages them for the retry. const staged = effectiveStagedQuotes(); const originSessionId = input.driver.getSessionId(); + // The lane the quotes were staged in must be read before the dispatch's + // own clear: clearStagedQuotes() resets the flag, so a read after it + // always records turn-staged and a failed submit's restage would hand the + // quotes to the next switch's staging clear (#5265 review). + const originFollowDraft = stagedQuotesFollowDraft; if (staged.length > 0) clearStagedQuotes(); else supersedePendingRestage(); // The generation is read after the dispatch's own clear: the restore @@ -1505,7 +1510,6 @@ 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 originFollowDraft = stagedQuotesFollowDraft; const restageForRetry = (): boolean => { if (!staged.length) return false; if (input.driver.getSessionId() !== originSessionId) return false;