Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
93a8fdf
fix(cli): stage a rewound turn's quotes into the replacement submit
ggbdpq Sep 13, 2026
eb5c88f
fix(cli): avoid unsafe optional chaining in the staged-quotes assertion
ggbdpq Sep 13, 2026
6ca7ff9
fix(cli): scope quote restaging to the origin draft and admit quote-o…
ggbdpq Sep 14, 2026
399f15e
fix(cli): make quote restaging reachable and generation-safe
ggbdpq Sep 16, 2026
8822a09
fix(cli): surface staged-quote state across switches, clears, and the…
ggbdpq Sep 17, 2026
d0c2967
Merge branch 'main' into fix/tui-rewind-quote-staging
ggbdpq Sep 18, 2026
a127b13
chore: retrigger CI after a flaky PTY resource-process test
ggbdpq Sep 18, 2026
935cbab
Merge remote-tracking branch 'upstream/main' into fix/tui-rewind-quot…
ggbdpq Sep 20, 2026
cb6d108
fix(cli): restage quotes on an unknown submit outcome and neutralize …
ggbdpq Sep 20, 2026
c07c577
fix(cli): route the unknown-outcome notice through the rewind copy ca…
ggbdpq Sep 20, 2026
dcb9c22
style(cli): collapse the shortened waitFor assertion to one line
ggbdpq Sep 20, 2026
6d5da11
Merge remote-tracking branch 'upstream/main' into fix/tui-rewind-quot…
ggbdpq Sep 26, 2026
b261e8d
fix(cli): supersede a pending quote restoration on submits and clears
ggbdpq Sep 26, 2026
fea2f7a
fix(cli): restage a retracted message's quotes instead of dropping them
ggbdpq Sep 27, 2026
92615d6
test(cli): expect the retraction quotes in the driver contract test
ggbdpq Sep 27, 2026
668a576
fix(cli): fence a retraction to the session it was asked for
ggbdpq Sep 27, 2026
d9aa90e
fix(cli): fence the whole retraction, and its stop, to the owning ses…
ggbdpq Sep 27, 2026
f0b31c9
fix(cli): serialize session switches behind an in-flight retraction
ggbdpq Sep 28, 2026
1677bbc
Merge upstream/main into fix/tui-rewind-quote-staging
ggbdpq Sep 28, 2026
a16a934
fix(cli): pin the idle switch race behind a held activity lease
ggbdpq Sep 28, 2026
b48832d
fix(cli): block Alt+Up retractions for the entire in-progress switch …
ggbdpq Sep 28, 2026
0ac4a7e
fix(cli): hold the switch window across side-conversation re-keys
ggbdpq Sep 29, 2026
0f26b77
fix(cli): drain in-flight retractions before side-conversation re-keys
ggbdpq Sep 30, 2026
bd25e82
fix(cli): keep a drained retraction's payload out of the wrong editor
ggbdpq Sep 30, 2026
5e53868
fix(cli): keep retraction-restored quotes and close-window typing fro…
ggbdpq Sep 30, 2026
eb0f8e4
fix(cli): keep retry provenance across failed submissions and switches
ggbdpq Sep 30, 2026
d8bef92
fix(cli): reserve the mid-turn detach before the retraction drain
ggbdpq Oct 8, 2026
aa2e162
Merge remote-tracking branch 'upstream/main' into fix/tui-rewind-quot…
ggbdpq Oct 8, 2026
737f470
fix(cli): retarget the retraction tests at Shift+Left and guard the d…
ggbdpq Oct 8, 2026
59cc0ef
Merge branch 'main' into fix/tui-rewind-quote-staging
ggbdpq Oct 10, 2026
4d77644
ci: retrigger test after #6053 landed the workhub-dock fix
ggbdpq Oct 10, 2026
5a9623e
split(cli): move the retraction/switch fencing to its own PR
ggbdpq Oct 11, 2026
a06a9e2
fix(cli): trace the quote count on the live transient user row
ggbdpq Oct 11, 2026
27a82ea
test(cli): cover retractQueued handing back non-empty quotes
ggbdpq Oct 11, 2026
b8b41ee
Merge remote-tracking branch 'origin/main' into fix/tui-rewind-quote-…
ggbdpq Oct 12, 2026
87fb4fc
fix(cli): keep multi-entry retraction restaging within the per-messag…
ggbdpq Oct 13, 2026
c5c5b79
Merge remote-tracking branch 'upstream/main' into fix/tui-rewind-quot…
ggbdpq Oct 13, 2026
b7c01c7
fix(cli): declare the quote-cap re-export a compatible protocol change
ggbdpq Oct 13, 2026
52ee6e5
fix(cli): keep overflow quote comments when restaging degrades to text
ggbdpq Oct 13, 2026
6d5d203
Merge remote-tracking branch 'upstream/main' into fix/tui-rewind-quot…
ggbdpq Oct 13, 2026
d0d142e
fix(cli): quote every overflow line and reformat the merged copy catalog
ggbdpq Oct 13, 2026
90ae756
style(cli): format the rewound-quotes overflow wait assertion
ggbdpq Oct 13, 2026
1950d49
test(cli): restore the table-driven retraction fencing tests from main
ggbdpq Oct 18, 2026
3fba85a
fix(cli): budget recovered quote staging by the encoded message size
ggbdpq Oct 18, 2026
5c6dac6
fix(cli): localize the side-close kept-draft and staged-quote notices
ggbdpq Oct 18, 2026
cf64e0a
fix(cli): budget rewind quote staging against the real editor text
ggbdpq Oct 18, 2026
8f5f9c3
Merge remote-tracking branch 'upstream/main' into fix/tui-rewind-quot…
ggbdpq Oct 23, 2026
69900ba
chore(protocol): re-pin the turn-admission re-export declaration to e…
ggbdpq Oct 23, 2026
305f411
style(protocol): format the re-pinned declaration
ggbdpq Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6777,6 +6777,212 @@ Slug openai-work<cursor>
]);
});

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:<n> segment.
await waitFor(() => plainTerminalOutput(terminal.output()).includes('quoted context'));
await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1'));

// The replacement submit carries the staged QuoteRefs verbatim.
terminal.input('answer with this context');
terminal.input('\r');
await waitFor(() => driver.submittedQuotes.length === 1);
assert.deepEqual(driver.submittedQuotes[0], [
{ text: 'a large pasted excerpt', label: 'earlier turn', sourceTurnId: 'turn-0' },
]);
// Staging is consumed by the submit it rode on.
terminal.input('plain follow-up');
terminal.input('\r');
await waitFor(() => driver.submittedQuotes.length === 2);
assert.equal(driver.submittedQuotes[1], undefined);

exitMaka(terminal);
await Promise.race([
run,
delay(CLOSE_BUDGET_MS).then(() => {
throw new Error('TUI did not close during test cleanup');
}),
]);
});

test('restores a failed quote submit only to its originating session', async () => {
const terminal = new FakeTerminal();
const driver = new HeldSubmitQuotedDriver(
[{ turnId: 'turn-1', label: 'first question' }],
[storedUserMessage('user-1', 'turn-1', 'first question')],
);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
});

terminal.input('/rewind');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('first question'));
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('quotes:1'));
terminal.input('resend this');
terminal.input('\r');
await waitFor(() => driver.submittedQuotes.length === 1);

// A Session switch lands while the admission is still pending.
driver.switchSession('session-other');
Comment thread
ggbdpq marked this conversation as resolved.
Outdated
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(
[{ 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(
Expand Down Expand Up @@ -11968,6 +12174,56 @@ 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<MakaSubmitMessageOptions['quotes']> = [];

override async rewindToTurn(turnId: string): Promise<MakaSessionRewindResult> {
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<TurnMessageSubmitResult | undefined> {
this.submittedQuotes.push(options.quotes);
return super.submitMessage(text, options);
}
}

/**
* The submit hangs until the test rejects it, so a failure callback can be
* observed after the runner moved on (for example across a Session switch).
* The rewound prompt is empty: a quote-only replacement (#5109 review).
*/
class HeldSubmitQuotedDriver extends QuotedRewindDriver {
hold!: (error: Error) => void;

override async rewindToTurn(turnId: string): Promise<MakaSessionRewindResult> {
const result = await super.rewindToTurn(turnId);
return { ...result, prompt: '' };
}

override submitMessage(
text: string,
options: MakaSubmitMessageOptions,
): Promise<TurnMessageSubmitResult | undefined> {
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
Expand Down
Loading